From 6650ee698e6af7b167a2388755471b847acd3b5d Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Aug 2024 11:29:52 -0500 Subject: [PATCH 1/9] feat: add initial views for upgrades CRUD only so far, still need execute features --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.upgrades.rst | 6 + src/wuttaweb/forms/schema.py | 104 ++++++++++++--- src/wuttaweb/grids/base.py | 4 +- src/wuttaweb/menus.py | 5 + src/wuttaweb/util.py | 26 ++++ src/wuttaweb/views/common.py | 5 + src/wuttaweb/views/essential.py | 2 + src/wuttaweb/views/master.py | 50 +++++++ src/wuttaweb/views/upgrades.py | 187 ++++++++++++++++++++++++++ tests/forms/test_schema.py | 61 ++++++++- tests/test_util.py | 190 ++++++++++++++------------- tests/views/test_master.py | 29 +++- tests/views/test_upgrades.py | 103 +++++++++++++++ 14 files changed, 656 insertions(+), 117 deletions(-) create mode 100644 docs/api/wuttaweb/views.upgrades.rst create mode 100644 src/wuttaweb/views/upgrades.py create mode 100644 tests/views/test_upgrades.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 9749cae..739935b 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -32,4 +32,5 @@ views.people views.roles views.settings + views.upgrades views.users diff --git a/docs/api/wuttaweb/views.upgrades.rst b/docs/api/wuttaweb/views.upgrades.rst new file mode 100644 index 0000000..2909003 --- /dev/null +++ b/docs/api/wuttaweb/views.upgrades.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.upgrades`` +=========================== + +.. automodule:: wuttaweb.views.upgrades + :members: diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 4402fde..8538eef 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -92,6 +92,53 @@ class ObjectNode(colander.SchemaNode): raise NotImplementedError(f"you must define {class_name}.objectify()") +class WuttaEnum(colander.Enum): + """ + Custom schema type for enum fields. + + This is a subclass of :class:`colander.Enum`, but adds a + default widget (``SelectWidget``) with enum choices. + + :param request: Current :term:`request` object. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def widget_maker(self, **kwargs): + """ """ + + if 'values' not in kwargs: + kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr)) + for e in self.enum_cls] + + return widgets.SelectWidget(**kwargs) + + +class WuttaSet(colander.Set): + """ + Custom schema type for :class:`python:set` fields. + + This is a subclass of :class:`colander.Set`, but adds + Wutta-related params to the constructor. + + :param request: Current :term:`request` object. + + :param session: Optional :term:`db session` to use instead of + :class:`wuttaweb.db.Session`. + """ + + def __init__(self, request, session=None): + super().__init__() + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.session = session or Session() + + class ObjectRef(colander.SchemaType): """ Custom schema type for a model class reference field. @@ -199,7 +246,7 @@ class ObjectRef(colander.SchemaType): # fetch object from DB model = self.app.model - obj = self.session.query(self.model_class).get(value) + obj = self.session.get(self.model_class, value) # raise error if not found if not obj: @@ -247,14 +294,28 @@ class ObjectRef(colander.SchemaType): kwargs['values'] = values if 'url' not in kwargs: - kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid) + kwargs['url'] = self.get_object_url return widgets.ObjectRefWidget(self.request, **kwargs) + def get_object_url(self, obj): + """ + Returns the "view" URL for the given object, if applicable. + + This is used when rendering the field readonly. If this + method returns a URL then the field text will be wrapped with + a hyperlink, otherwise it will be shown as-is. + + Default logic always returns ``None``; subclass should + override as needed. + """ + class PersonRef(ObjectRef): """ - Custom schema type for a ``Person`` reference field. + Custom schema type for a + :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference + field. This is a subclass of :class:`ObjectRef`. """ @@ -269,26 +330,33 @@ class PersonRef(ObjectRef): """ """ return query.order_by(self.model_class.full_name) + def get_object_url(self, person): + """ """ + return self.request.route_url('people.view', uuid=person.uuid) -class WuttaSet(colander.Set): + +class UserRef(ObjectRef): """ - Custom schema type for :class:`python:set` fields. + Custom schema type for a + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference + field. - This is a subclass of :class:`colander.Set`, but adds - Wutta-related params to the constructor. - - :param request: Current :term:`request` object. - - :param session: Optional :term:`db session` to use instead of - :class:`wuttaweb.db.Session`. + This is a subclass of :class:`ObjectRef`. """ - def __init__(self, request, session=None): - super().__init__() - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - self.session = session or Session() + @property + def model_class(self): + """ """ + model = self.app.model + return model.User + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.username) + + def get_object_url(self, user): + """ """ + return self.request.route_url('users.view', uuid=user.uuid) class RoleRefs(WuttaSet): diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 0f2c812..51b0d7d 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -1078,6 +1078,7 @@ class Grid: :returns: A :class:`~wuttaweb.grids.filters.GridFilter` instance. """ + key = kwargs.pop('key', None) # model_property is required model_property = None @@ -1102,7 +1103,7 @@ class Grid: # make filter kwargs['model_property'] = model_property - return factory(self.request, model_property.key, **kwargs) + return factory(self.request, key or model_property.key, **kwargs) def set_filter(self, key, filterinfo=None, **kwargs): """ @@ -1132,6 +1133,7 @@ class Grid: # filtr = filterinfo raise NotImplementedError else: + kwargs['key'] = key kwargs.setdefault('label', self.get_label(key)) filtr = self.make_filter(filterinfo or key, **kwargs) diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 84d5534..c1c47dc 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -168,6 +168,11 @@ class MenuHandler(GenericHandler): 'route': 'settings', 'perm': 'settings.list', }, + { + 'title': "Upgrades", + 'route': 'upgrades', + 'perm': 'upgrades.list', + }, ], } diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 0b230b4..29065fa 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -83,6 +83,32 @@ class FieldList(list): field, newfield) self.append(newfield) + def set_sequence(self, fields): + """ + Sort the list such that it matches the same sequence as the + given fields list. + + This does not add or remove any elements, it just + (potentially) rearranges the internal list elements. + Therefore you do not need to explicitly declare *all* fields; + just the ones you care about. + + The resulting field list will have the requested fields in + order, at the *beginning* of the list. Any unrequested fields + will remain in the same order as they were previously, but + will be placed *after* the requested fields. + + :param fields: List of fields in the desired order. + """ + unimportant = len(self) + 1 + + def getkey(field): + if field in fields: + return fields.index(field) + return unimportant + + self.sort(key=getkey) + def get_form_data(request): """ diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index a13fc50..2b6b084 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -154,6 +154,11 @@ class CommonView(View): 'settings.view', 'settings.edit', 'settings.delete', + 'upgrades.list', + 'upgrades.create', + 'upgrades.view', + 'upgrades.edit', + 'upgrades.delete', 'users.list', 'users.create', 'users.view', diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 1387a99..b6a05b1 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -35,6 +35,7 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.people` * :mod:`wuttaweb.views.roles` * :mod:`wuttaweb.views.users` +* :mod:`wuttaweb.views.upgrades` """ @@ -47,6 +48,7 @@ def defaults(config, **kwargs): config.include(mod('wuttaweb.views.people')) config.include(mod('wuttaweb.views.roles')) config.include(mod('wuttaweb.views.users')) + config.include(mod('wuttaweb.views.upgrades')) def includeme(config): diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index a834b1d..bc6dbb7 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -1040,6 +1040,56 @@ class MasterView(View): fmt = f"${{:0,.{scale}f}}" return fmt.format(value) + def grid_render_datetime(self, record, key, value, fmt=None): + """ + Custom grid value renderer for + :class:`~python:datetime.datetime` fields. + + :param fmt: Optional format string to use instead of the + default: ``'%Y-%m-%d %I:%M:%S %p'`` + + To use this feature for your grid:: + + grid.set_renderer('my_datetime_field', self.grid_render_datetime) + + # you can also override format + grid.set_renderer('my_datetime_field', self.grid_render_datetime, + fmt='%Y-%m-%d %H:%M:%S') + """ + # nb. get new value since the one provided will just be a + # (json-safe) *string* if the original type was datetime + value = record[key] + + if value is None: + return + + return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p') + + def grid_render_enum(self, record, key, value, enum=None): + """ + Custom grid value renderer for "enum" fields. + + :param enum: Enum class for the field. This should be an + instance of :class:`~python:enum.Enum`. + + To use this feature for your grid:: + + from enum import Enum + + class MyEnum(Enum): + ONE = 1 + TWO = 2 + THREE = 3 + + grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum) + """ + if enum: + original = record[key] + if original: + return original.name + + return value + def grid_render_notes(self, record, key, value, maxlen=100): """ Custom grid value renderer for "notes" fields. diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py new file mode 100644 index 0000000..8d7c48c --- /dev/null +++ b/src/wuttaweb/views/upgrades.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework 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. +# +# Wutta Framework 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 +# Wutta Framework. If not, see . +# +################################################################################ +""" +Upgrade Views +""" + +from sqlalchemy import orm + +from wuttjamaican.db.model import Upgrade +from wuttaweb.views import MasterView +from wuttaweb.forms import widgets +from wuttaweb.forms.schema import UserRef, WuttaEnum + + +class UpgradeView(MasterView): + """ + Master view for upgrades. + + Default route prefix is ``upgrades``. + + Notable URLs provided by this class: + + * ``/upgrades/`` + * ``/upgrades/new`` + * ``/upgrades/XXX`` + * ``/upgrades/XXX/edit`` + * ``/upgrades/XXX/delete`` + """ + model_class = Upgrade + + grid_columns = [ + 'created', + 'description', + 'status', + 'executed', + 'executed_by', + ] + + sort_defaults = ('created', 'desc') + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.app.model + enum = self.app.enum + + # description + g.set_link('description') + + # created + g.set_renderer('created', self.grid_render_datetime) + + # created_by + g.set_link('created_by') + Creator = orm.aliased(model.User) + g.set_joiner('created_by', lambda q: q.join(Creator, + Creator.uuid == model.Upgrade.created_by_uuid)) + g.set_filter('created_by', Creator.username, + label="Created By Username") + + # status + g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) + + # executed_by + g.set_link('executed_by') + Executor = orm.aliased(model.User) + g.set_joiner('executed_by', lambda q: q.outerjoin(Executor, + Executor.uuid == model.Upgrade.executed_by_uuid)) + g.set_filter('executed_by', Executor.username, + label="Executed By Username") + + def grid_row_class(self, upgrade, data, i): + """ """ + enum = self.app.enum + if upgrade.status == enum.UpgradeStatus.EXECUTING: + return 'has-background-warning' + if upgrade.status == enum.UpgradeStatus.FAILURE: + return 'has-background-warning' + + def configure_form(self, f): + """ """ + super().configure_form(f) + enum = self.app.enum + upgrade = f.model_instance + + # never show these + f.remove('created_by_uuid', + 'executing', + 'executed_by_uuid') + + # sequence sanity + f.fields.set_sequence([ + 'description', + 'notes', + 'status', + 'created', + 'created_by', + 'executed', + 'executed_by', + ]) + + # created + if self.creating or self.editing: + f.remove('created') + + # created_by + if self.creating or self.editing: + f.remove('created_by') + else: + f.set_node('created_by', UserRef(self.request)) + + # notes + f.set_widget('notes', widgets.NotesWidget()) + + # status + if self.creating: + f.remove('status') + else: + f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) + + # exit_code + if self.creating or not upgrade.executed: + f.remove('exit_code') + + # executed + if self.creating or self.editing or not upgrade.executed: + f.remove('executed') + + # executed_by + if self.creating or self.editing or not upgrade.executed: + f.remove('executed_by') + else: + f.set_node('executed_by', UserRef(self.request)) + + def objectify(self, form): + """ """ + upgrade = super().objectify(form) + enum = self.app.enum + + # set user, status when creating + if self.creating: + upgrade.created_by = self.request.user + upgrade.status = enum.UpgradeStatus.PENDING + + return upgrade + + @classmethod + def defaults(cls, config): + """ """ + + # nb. Upgrade may come from custom model + wutta_config = config.registry.settings['wutta_config'] + app = wutta_config.get_app() + cls.model_class = app.model.Upgrade + + cls._defaults(config) + + +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) + UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 7538301..9397e03 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -10,7 +10,7 @@ from sqlalchemy import orm from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import schema as mod from wuttaweb.forms import widgets -from tests.util import DataTestCase +from tests.util import DataTestCase, WebTestCase class TestObjectNode(DataTestCase): @@ -47,6 +47,15 @@ class TestObjectNode(DataTestCase): self.assertIs(value, person) +class TestWuttaEnum(WebTestCase): + + def test_widget_maker(self): + enum = self.app.enum + typ = mod.WuttaEnum(self.request, enum.UpgradeStatus) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.SelectWidget) + + class TestObjectRef(DataTestCase): def setUp(self): @@ -140,10 +149,17 @@ class TestObjectRef(DataTestCase): self.session.commit() self.assertIsNotNone(person.uuid) with patch.object(mod.ObjectRef, 'model_class', new=model.Person): + + # can specify as uuid typ = mod.ObjectRef(self.request, session=self.session) value = typ.objectify(person.uuid) self.assertIs(value, person) + # or can specify object proper + typ = mod.ObjectRef(self.request, session=self.session) + value = typ.objectify(person) + self.assertIs(value, person) + # error if not found with patch.object(mod.ObjectRef, 'model_class', new=model.Person): typ = mod.ObjectRef(self.request, session=self.session) @@ -186,11 +202,7 @@ class TestObjectRef(DataTestCase): self.assertEqual(widget.values[1][1], "Betty Boop") -class TestPersonRef(DataTestCase): - - def setUp(self): - self.setup_db() - self.request = testing.DummyRequest(wutta_config=self.config) +class TestPersonRef(WebTestCase): def test_sort_query(self): typ = mod.PersonRef(self.request, session=self.session) @@ -200,6 +212,43 @@ class TestPersonRef(DataTestCase): self.assertIsInstance(sorted_query, orm.Query) self.assertIsNot(sorted_query, query) + def test_get_object_url(self): + self.pyramid_config.add_route('people.view', '/people/{uuid}') + model = self.app.model + typ = mod.PersonRef(self.request, session=self.session) + + person = model.Person(full_name="Barney Rubble") + self.session.add(person) + self.session.commit() + + url = typ.get_object_url(person) + self.assertIsNotNone(url) + self.assertIn(f'/people/{person.uuid}', url) + + +class TestUserRef(WebTestCase): + + def test_sort_query(self): + typ = mod.UserRef(self.request, session=self.session) + query = typ.get_query() + self.assertIsInstance(query, orm.Query) + sorted_query = typ.sort_query(query) + self.assertIsInstance(sorted_query, orm.Query) + self.assertIsNot(sorted_query, query) + + def test_get_object_url(self): + self.pyramid_config.add_route('users.view', '/users/{uuid}') + model = self.app.model + typ = mod.UserRef(self.request, session=self.session) + + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + + url = typ.get_object_url(user) + self.assertIsNotNone(url) + self.assertIn(f'/users/{user.uuid}', url) + class TestUserRefs(DataTestCase): diff --git a/tests/test_util.py b/tests/test_util.py index 021394b..0f54932 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,13 +9,13 @@ from fanstatic import Library, Resource from pyramid import testing from wuttjamaican.conf import WuttaConfig -from wuttaweb import util +from wuttaweb import util as mod class TestFieldList(TestCase): def test_insert_before(self): - fields = util.FieldList(['f1', 'f2']) + fields = mod.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -29,7 +29,7 @@ class TestFieldList(TestCase): self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ']) def test_insert_after(self): - fields = util.FieldList(['f1', 'f2']) + fields = mod.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -42,6 +42,14 @@ class TestFieldList(TestCase): fields.insert_after('f3', 'ZZZ') self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ']) + def test_set_sequence(self): + fields = mod.FieldList(['f5', 'f1', 'f3', 'f4', 'f2']) + + # setting sequence will only "sort" for explicit fields. + # other fields remain in original order, but at the end. + fields.set_sequence(['f1', 'f2', 'f3']) + self.assertEqual(fields, ['f1', 'f2', 'f3', 'f5', 'f4']) + class TestGetLibVer(TestCase): @@ -51,153 +59,153 @@ class TestGetLibVer(TestCase): self.request.wutta_config = self.config def test_buefy_default(self): - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, 'latest') def test_buefy_custom_old(self): self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_custom_old_tailbone(self): self.config.setdefault('tailbone.libver.buefy', '0.9.28') - version = util.get_libver(self.request, 'buefy', prefix='tailbone') + version = mod.get_libver(self.request, 'buefy', prefix='tailbone') self.assertEqual(version, '0.9.28') def test_buefy_custom_new(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy') + version = mod.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_configured_only(self): - version = util.get_libver(self.request, 'buefy', configured_only=True) + version = mod.get_libver(self.request, 'buefy', configured_only=True) self.assertIsNone(version) def test_buefy_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy', default_only=True) + version = mod.get_libver(self.request, 'buefy', default_only=True) self.assertEqual(version, 'latest') def test_buefy_css_default(self): - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, 'latest') def test_buefy_css_custom_old(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_custom_new(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy.css') + version = mod.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_configured_only(self): - version = util.get_libver(self.request, 'buefy.css', configured_only=True) + version = mod.get_libver(self.request, 'buefy.css', configured_only=True) self.assertIsNone(version) def test_buefy_css_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = util.get_libver(self.request, 'buefy.css', default_only=True) + version = mod.get_libver(self.request, 'buefy.css', default_only=True) self.assertEqual(version, 'latest') def test_vue_default(self): - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '2.6.14') def test_vue_custom_old(self): self.config.setdefault('wuttaweb.vue_version', '3.4.31') - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_custom_new(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = util.get_libver(self.request, 'vue') + version = mod.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_configured_only(self): - version = util.get_libver(self.request, 'vue', configured_only=True) + version = mod.get_libver(self.request, 'vue', configured_only=True) self.assertIsNone(version) def test_vue_default_only(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = util.get_libver(self.request, 'vue', default_only=True) + version = mod.get_libver(self.request, 'vue', default_only=True) self.assertEqual(version, '2.6.14') def test_vue_resource_default(self): - version = util.get_libver(self.request, 'vue_resource') + version = mod.get_libver(self.request, 'vue_resource') self.assertEqual(version, 'latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.libver.vue_resource', '1.5.3') - version = util.get_libver(self.request, 'vue_resource') + version = mod.get_libver(self.request, 'vue_resource') self.assertEqual(version, '1.5.3') def test_fontawesome_default(self): - version = util.get_libver(self.request, 'fontawesome') + version = mod.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.3.1') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.fontawesome', '5.6.3') - version = util.get_libver(self.request, 'fontawesome') + version = mod.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.6.3') def test_bb_vue_default(self): - version = util.get_libver(self.request, 'bb_vue') + version = mod.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.31') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue', '3.4.30') - version = util.get_libver(self.request, 'bb_vue') + version = mod.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.30') def test_bb_oruga_default(self): - version = util.get_libver(self.request, 'bb_oruga') + version = mod.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.12') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga', '0.8.11') - version = util.get_libver(self.request, 'bb_oruga') + version = mod.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.11') def test_bb_oruga_bulma_default(self): - version = util.get_libver(self.request, 'bb_oruga_bulma') + version = mod.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.3.0') - version = util.get_libver(self.request, 'bb_oruga_bulma_css') + version = mod.get_libver(self.request, 'bb_oruga_bulma_css') self.assertEqual(version, '0.3.0') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga_bulma', '0.2.11') - version = util.get_libver(self.request, 'bb_oruga_bulma') + version = mod.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.2.11') def test_bb_fontawesome_svg_core_default(self): - version = util.get_libver(self.request, 'bb_fontawesome_svg_core') + version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.2') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.libver.bb_fontawesome_svg_core', '6.5.1') - version = util.get_libver(self.request, 'bb_fontawesome_svg_core') + version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.1') def test_bb_free_solid_svg_icons_default(self): - version = util.get_libver(self.request, 'bb_free_solid_svg_icons') + version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.2') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.libver.bb_free_solid_svg_icons', '6.5.1') - version = util.get_libver(self.request, 'bb_free_solid_svg_icons') + version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.1') def test_bb_vue_fontawesome_default(self): - version = util.get_libver(self.request, 'bb_vue_fontawesome') + version = mod.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.6') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue_fontawesome', '3.0.8') - version = util.get_libver(self.request, 'bb_vue_fontawesome') + version = mod.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.8') @@ -238,191 +246,191 @@ class TestGetLibUrl(TestCase): self.request.script_name = '/wutta' def test_buefy_default(self): - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_custom(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, '/lib/buefy.js') def test_buefy_custom_tailbone(self): self.config.setdefault('tailbone.liburl.buefy', '/tailbone/buefy.js') - url = util.get_liburl(self.request, 'buefy', prefix='tailbone') + url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/tailbone/buefy.js') def test_buefy_default_only(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = util.get_liburl(self.request, 'buefy', default_only=True) + url = mod.get_liburl(self.request, 'buefy', default_only=True) self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_configured_only(self): - url = util.get_liburl(self.request, 'buefy', configured_only=True) + url = mod.get_liburl(self.request, 'buefy', configured_only=True) self.assertIsNone(url) def test_buefy_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'buefy') + url = mod.get_liburl(self.request, 'buefy') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_fanstatic_tailbone(self): self.setup_fanstatic(register=False) self.config.setdefault('tailbone.static_libcache.module', 'tests.test_util') - url = util.get_liburl(self.request, 'buefy', prefix='tailbone') + url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_css_default(self): - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css') def test_buefy_css_custom(self): self.config.setdefault('wuttaweb.liburl.buefy.css', '/lib/buefy.css') - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/lib/buefy.css') def test_buefy_css_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'buefy.css') + url = mod.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/wutta/fanstatic/buefy.css') def test_vue_default(self): - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, 'https://unpkg.com/vue@2.6.14/dist/vue.min.js') def test_vue_custom(self): self.config.setdefault('wuttaweb.liburl.vue', '/lib/vue.js') - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, '/lib/vue.js') def test_vue_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'vue') + url = mod.get_liburl(self.request, 'vue') self.assertEqual(url, '/wutta/fanstatic/vue.js') def test_vue_resource_default(self): - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/vue-resource@latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.liburl.vue_resource', '/lib/vue-resource.js') - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/lib/vue-resource.js') def test_vue_resource_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'vue_resource') + url = mod.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/wutta/fanstatic/vue_resource.js') def test_fontawesome_default(self): - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, 'https://use.fontawesome.com/releases/v5.3.1/js/all.js') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.fontawesome', '/lib/fontawesome.js') - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/lib/fontawesome.js') def test_fontawesome_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'fontawesome') + url = mod.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/wutta/fanstatic/fontawesome.js') def test_bb_vue_default(self): - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, 'https://unpkg.com/vue@3.4.31/dist/vue.esm-browser.prod.js') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue', '/lib/vue.js') - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/lib/vue.js') def test_bb_vue_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_vue') + url = mod.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/wutta/fanstatic/bb_vue.js') def test_bb_oruga_default(self): - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/oruga-next@0.8.12/dist/oruga.mjs') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga', '/lib/oruga.js') - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/lib/oruga.js') def test_bb_oruga_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga') + url = mod.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/wutta/fanstatic/bb_oruga.js') def test_bb_oruga_bulma_default(self): - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.mjs') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma', '/lib/oruga_bulma.js') - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/lib/oruga_bulma.js') def test_bb_oruga_bulma_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga_bulma') + url = mod.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.js') def test_bb_oruga_bulma_css_default(self): - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.css') def test_bb_oruga_bulma_css_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma_css', '/lib/oruga-bulma.css') - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/lib/oruga-bulma.css') def test_bb_oruga_bulma_css_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_oruga_bulma_css') + url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.css') def test_bb_fontawesome_svg_core_default(self): - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@6.5.2/+esm') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.liburl.bb_fontawesome_svg_core', '/lib/fontawesome-svg-core.js') - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/lib/fontawesome-svg-core.js') def test_bb_fontawesome_svg_core_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/wutta/fanstatic/bb_fontawesome_svg_core.js') def test_bb_free_solid_svg_icons_default(self): - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@6.5.2/+esm') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.liburl.bb_free_solid_svg_icons', '/lib/free-solid-svg-icons.js') - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/lib/free-solid-svg-icons.js') def test_bb_free_solid_svg_icons_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/wutta/fanstatic/bb_free_solid_svg_icons.js') def test_bb_vue_fontawesome_default(self): - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@3.0.6/+esm') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js') - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/lib/vue-fontawesome.js') def test_bb_vue_fontawesome_fanstatic(self): self.setup_fanstatic() - url = util.get_liburl(self.request, 'bb_vue_fontawesome') + url = mod.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/wutta/fanstatic/bb_vue_fontawesome.js') @@ -439,17 +447,17 @@ class TestGetFormData(TestCase): def test_default(self): request = self.make_request() - data = util.get_form_data(request) + data = mod.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) + data = mod.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) + data = mod.get_form_data(request) self.assertEqual(data, {'foo2': 'baz'}) @@ -460,16 +468,16 @@ class TestGetModelFields(TestCase): self.app = self.config.get_app() def test_empty_model_class(self): - fields = util.get_model_fields(self.config) + fields = mod.get_model_fields(self.config) self.assertIsNone(fields) def test_unknown_model_class(self): - fields = util.get_model_fields(self.config, TestCase) + fields = mod.get_model_fields(self.config, TestCase) self.assertIsNone(fields) def test_basic(self): model = self.app.model - fields = util.get_model_fields(self.config, model.Setting) + fields = mod.get_model_fields(self.config, model.Setting) self.assertEqual(fields, ['name', 'value']) @@ -484,9 +492,9 @@ class TestGetCsrfToken(TestCase): # same token returned for same request # TODO: dummy request is always returning same token! # so this isn't really testing anything.. :( - first = util.get_csrf_token(self.request) + first = mod.get_csrf_token(self.request) self.assertIsNotNone(first) - second = util.get_csrf_token(self.request) + second = mod.get_csrf_token(self.request) self.assertEqual(first, second) # TODO: ideally would make a new request here and confirm it @@ -497,7 +505,7 @@ class TestGetCsrfToken(TestCase): # nb. dummy request always returns same token, so must # trick it into thinking it doesn't have one yet with patch.object(self.request.session, 'get_csrf_token', return_value=None): - token = util.get_csrf_token(self.request) + token = mod.get_csrf_token(self.request) self.assertIsNotNone(token) @@ -508,10 +516,10 @@ class TestRenderCsrfToken(TestCase): self.request = testing.DummyRequest(wutta_config=self.config) def test_basics(self): - html = util.render_csrf_token(self.request) + html = mod.render_csrf_token(self.request) self.assertIn('type="hidden"', html) self.assertIn('name="_csrf"', html) - token = util.get_csrf_token(self.request) + token = mod.get_csrf_token(self.request) self.assertIn(f'value="{token}"', html) @@ -522,17 +530,17 @@ class TestMakeJsonSafe(TestCase): self.app = self.config.get_app() def test_null(self): - value = util.make_json_safe(colander.null) + value = mod.make_json_safe(colander.null) self.assertIsNone(value) - value = util.make_json_safe(None) + value = mod.make_json_safe(None) self.assertIsNone(value) def test_invalid(self): model = self.app.model person = model.Person(full_name="Betty Boop") self.assertRaises(TypeError, json.dumps, person) - value = util.make_json_safe(person, key='person') + value = mod.make_json_safe(person, key='person') self.assertEqual(value, "Betty Boop") def test_dict(self): @@ -545,7 +553,7 @@ class TestMakeJsonSafe(TestCase): } self.assertRaises(TypeError, json.dumps, data) - value = util.make_json_safe(data) + value = mod.make_json_safe(data) self.assertEqual(value, { 'foo': 'bar', 'person': "Betty Boop", diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 023449a..2e5f8a7 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,5 +1,6 @@ # -*- coding: utf-8; -*- +import datetime import decimal import functools from unittest import TestCase @@ -579,7 +580,6 @@ class TestMasterView(WebTestCase): self.assertEqual(value, "No") def test_grid_render_currency(self): - model = self.app.model view = self.make_view() obj = {'amount': None} @@ -597,6 +597,33 @@ class TestMasterView(WebTestCase): value = view.grid_render_currency(obj, 'amount', '-100.42') self.assertEqual(value, "($100.42)") + def test_grid_render_datetime(self): + view = self.make_view() + obj = {'dt': None} + + # null + value = view.grid_render_datetime(obj, 'dt', None) + self.assertIsNone(value) + + # normal + obj['dt'] = datetime.datetime(2024, 8, 24, 11) + value = view.grid_render_datetime(obj, 'dt', '2024-08-24T11:00:00') + self.assertEqual(value, '2024-08-24 11:00:00 AM') + + def test_grid_render_enum(self): + enum = self.app.enum + view = self.make_view() + obj = {'status': None} + + # null + value = view.grid_render_enum(obj, 'status', None, enum=enum.UpgradeStatus) + self.assertIsNone(value) + + # normal + obj['status'] = enum.UpgradeStatus.SUCCESS + value = view.grid_render_enum(obj, 'status', 'SUCCESS', enum=enum.UpgradeStatus) + self.assertEqual(value, 'SUCCESS') + def test_grid_render_notes(self): model = self.app.model view = self.make_view() diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py new file mode 100644 index 0000000..4641029 --- /dev/null +++ b/tests/views/test_upgrades.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8; -*- + +import datetime +from unittest.mock import patch, MagicMock + +from wuttaweb.views import upgrades as mod +from tests.util import WebTestCase + + +class TestUpgradeView(WebTestCase): + + def make_view(self): + return mod.UpgradeView(self.request) + + def test_includeme(self): + self.pyramid_config.include('wuttaweb.views.upgrades') + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid(model_class=model.Upgrade) + view.configure_grid(grid) + + def test_grid_row_class(self): + model = self.app.model + enum = self.app.enum + upgrade = model.Upgrade(description="test", status=enum.UpgradeStatus.PENDING) + data = dict(upgrade) + view = self.make_view() + + self.assertIsNone(view.grid_row_class(upgrade, data, 1)) + + upgrade.status = enum.UpgradeStatus.EXECUTING + self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') + + upgrade.status = enum.UpgradeStatus.SUCCESS + self.assertIsNone(view.grid_row_class(upgrade, data, 1)) + + upgrade.status = enum.UpgradeStatus.FAILURE + self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') + + def test_configure_form(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + self.session.add(user) + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + view = self.make_view() + + # some fields exist when viewing + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('created', form) + view.configure_form(form) + self.assertIn('created', form) + + # but then are removed when creating + with patch.object(view, 'creating', new=True): + form = view.make_form(model_class=model.Upgrade) + self.assertIn('created', form) + view.configure_form(form) + self.assertNotIn('created', form) + + # test executed field when viewing + with patch.object(view, 'viewing', new=True): + + # executed is *not* shown by default + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('executed', form) + view.configure_form(form) + self.assertNotIn('executed', form) + + # but it *is* shown if upgrade is executed + upgrade.executed = datetime.datetime.now() + form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) + self.assertIn('executed', form) + view.configure_form(form) + self.assertIn('executed', form) + + def test_objectify(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + view = self.make_view() + + # user and status are auto-set when creating + self.request.user = user + self.request.method = 'POST' + self.request.POST = {'description': "new one"} + with patch.object(view, 'creating', new=True): + form = view.make_model_form() + self.assertTrue(form.validate()) + upgrade = view.objectify(form) + self.assertEqual(upgrade.description, "new one") + self.assertIs(upgrade.created_by, user) + self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) From 6fa8b0aeaaf1ffb2b5dfe0895d1c89b51e57e0e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Aug 2024 14:26:13 -0500 Subject: [PATCH 2/9] feat: add basic "delete results" grid tool this is done synchronously with no progress indicator yet --- src/wuttaweb/grids/base.py | 41 +++- .../templates/grids/vue_template.mako | 21 ++ src/wuttaweb/templates/master/index.mako | 35 ++++ src/wuttaweb/views/common.py | 1 + src/wuttaweb/views/master.py | 186 ++++++++++++++++-- src/wuttaweb/views/settings.py | 1 + tests/grids/test_base.py | 36 ++++ tests/views/test_master.py | 118 +++++++++++ 8 files changed, 426 insertions(+), 13 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 51b0d7d..4ff990e 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -28,7 +28,7 @@ import functools import json import logging import warnings -from collections import namedtuple +from collections import namedtuple, OrderedDict import sqlalchemy as sa from sqlalchemy import orm @@ -339,6 +339,16 @@ class Grid: sorting. See :meth:`set_joiner()` for more info. + + .. attribute:: tools + + Dict of "tool" elements for the grid. Tools are usually buttons + (e.g. "Delete Results"), shown on top right of the grid. + + The keys for this dict are somewhat arbitrary, defined by the + caller. Values should be HTML literal elements. + + See also :meth:`add_tool()` and :meth:`set_tools()`. """ def __init__( @@ -369,6 +379,7 @@ class Grid: filters=None, filter_defaults=None, joiners=None, + tools=None, ): self.request = request self.vue_tagname = vue_tagname @@ -386,6 +397,7 @@ class Grid: self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) + self.set_tools(tools) # sorting self.sortable = sortable @@ -658,6 +670,33 @@ class Grid: """ self.actions.append(GridAction(self.request, key, **kwargs)) + def set_tools(self, tools): + """ + Set the :attr:`tools` attribute using the given tools collection. + + This will normalize the list/dict to desired internal format. + """ + if tools and isinstance(tools, list): + if not any([isinstance(t, (tuple, list)) for t in tools]): + tools = [(self.app.make_uuid(), t) for t in tools] + self.tools = OrderedDict(tools or []) + + def add_tool(self, html, key=None): + """ + Add a new HTML snippet to the :attr:`tools` dict. + + :param html: HTML literal for the tool element. + + :param key: Optional key to use when adding to the + :attr:`tools` dict. If not specified, a random string is + generated. + + See also :meth:`set_tools()`. + """ + if not key: + key = self.app.make_uuid() + self.tools[key] = html + ############################## # joining methods ############################## diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 1ed408e..e3a7a23 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -90,6 +90,19 @@ % endif +
+ + ## nb. this is needed to force tools to bottom + ## TODO: should we put a context menu here? +
+ +
+ % for html in grid.tools.values(): + ${html} + % endfor +
+
+ <${b}-table :data="data" @@ -290,6 +303,14 @@ template: '#${grid.vue_tagname}-template', computed: { + recordCount() { + % if grid.paginated: + return this.pagerStats.item_count + % else: + return this.data.length + % endif + }, + directLink() { const params = new URLSearchParams(this.getAllParams()) return `${request.path_url}?${'$'}{params}` diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako index a16aced..bf32c6f 100644 --- a/src/wuttaweb/templates/master/index.mako +++ b/src/wuttaweb/templates/master/index.mako @@ -23,6 +23,41 @@ % endif +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.deletable_bulk and master.has_perm('delete_bulk'): + + % endif + + <%def name="make_vue_components()"> ${parent.make_vue_components()} % if grid is not Undefined: diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index 2b6b084..8c6dc25 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -154,6 +154,7 @@ class CommonView(View): 'settings.view', 'settings.edit', 'settings.delete', + 'settings.delete_bulk', 'upgrades.list', 'upgrades.create', 'upgrades.view', diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index bc6dbb7..d1e9bef 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -24,6 +24,8 @@ Base Logic for Master Views """ +import logging + import sqlalchemy as sa from sqlalchemy import orm @@ -31,11 +33,14 @@ from pyramid.renderers import render_to_response from webhelpers2.html import HTML from wuttaweb.views import View -from wuttaweb.util import get_form_data, get_model_fields +from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token from wuttaweb.db import Session from wuttjamaican.util import get_class_hierarchy +log = logging.getLogger(__name__) + + class MasterView(View): """ Base class for "master" views. @@ -284,6 +289,12 @@ class MasterView(View): See also :meth:`is_deletable()`. + .. attribute:: deletable_bulk + + Boolean indicating whether the view model supports "bulk + deleting" - i.e. it should have a :meth:`delete_bulk()` view. + Default value is ``False``. + .. attribute:: form_fields List of fields for the model form. @@ -321,6 +332,7 @@ class MasterView(View): viewable = True editable = True deletable = True + deletable_bulk = False has_autocomplete = False configurable = False @@ -622,6 +634,76 @@ class MasterView(View): session = self.app.get_session(obj) session.delete(obj) + def delete_bulk(self, session=None): + """ + View to delete all records in the current :meth:`index()` grid + data set, i.e. those matching current query. + + This usually corresponds to a URL like + ``/widgets/delete-bulk``. + + By default, this view is included only if + :attr:`deletable_bulk` is true. + + This view requires POST method. When it is finished deleting, + user is redirected back to :meth:`index()` view. + + Subclass normally should not override this method, but rather + one of the related methods which are called (in)directly by + this one: + + * :meth:`delete_bulk_data()` + """ + # get current data set from grid + # nb. this must *not* be paginated, we need it all + grid = self.make_model_grid(paginated=False) + data = grid.get_visible_data() + + # delete it all and go back to listing + self.delete_bulk_data(data, session=session) + return self.redirect(self.get_index_url()) + + def delete_bulk_data(self, data, session=None): + """ + This method performs the actual bulk deletion, for the given + data set. + + Default logic will call :meth:`is_deletable()` for every data + record, and if that returns true then it calls + :meth:`delete_instance()`. + + As of now there is no progress indicator or async; caller must + simply wait until delete is finished. + """ + session = session or self.Session() + + for obj in data: + if self.is_deletable(obj): + self.delete_instance(obj) + + def delete_bulk_make_button(self): + """ """ + route_prefix = self.get_route_prefix() + + label = HTML.literal( + '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}') + button = self.make_button(label, + variant='is-danger', + icon_left='trash', + **{'@click': 'deleteResultsSubmit()', + ':disabled': 'deleteResultsDisabled'}) + + form = HTML.tag('form', + method='post', + action=self.request.route_url(f'{route_prefix}.delete_bulk'), + ref='deleteResultsForm', + class_='control', + c=[ + render_csrf_token(self.request), + button, + ]) + return form + ############################## # autocomplete methods ############################## @@ -1168,6 +1250,64 @@ class MasterView(View): return True return False + def make_button( + self, + label, + variant=None, + primary=False, + **kwargs, + ): + """ + Make and return a HTML ```` literal. + + :param label: Text label for the button. + + :param variant: This is the "Buefy type" (or "Oruga variant") + for the button. Buefy and Oruga represent this differently + but this logic expects the Buefy format + (e.g. ``is-danger``) and *not* the Oruga format + (e.g. ``danger``), despite the param name matching Oruga's + terminology. + + :param type: This param is not advertised in the method + signature, but if caller specifies ``type`` instead of + ``variant`` it should work the same. + + :param primary: If neither ``variant`` nor ``type`` are + specified, this flag may be used to automatically set the + Buefy type to ``is-primary``. + + This is the preferred method where applicable, since it + avoids the Buefy vs. Oruga confusion, and the + implementation can change in the future. + + :param \**kwargs: All remaining kwargs are passed to the + underlying ``HTML.tag()`` call, so will be rendered as + attributes on the button tag. + + :returns: HTML literal for the button element. Will be something + along the lines of: + + .. code-block:: + + + Click Me + + """ + btn_kw = kwargs + btn_kw.setdefault('c', label) + btn_kw.setdefault('icon_pack', 'fas') + + if 'type' not in btn_kw: + if variant: + btn_kw['type'] = variant + elif primary: + btn_kw['type'] = 'is-primary' + + return HTML.tag('b-button', **btn_kw) + def render_to_response(self, template, context): """ Locate and render an appropriate template, with the given @@ -1378,6 +1518,14 @@ class MasterView(View): kwargs['actions'] = actions + if 'tools' not in kwargs: + tools = [] + + if self.deletable_bulk and self.has_perm('delete_bulk'): + tools.append(('delete-results', self.delete_bulk_make_button())) + + kwargs['tools'] = tools + if hasattr(self, 'grid_row_class'): kwargs.setdefault('row_class', self.grid_row_class) kwargs.setdefault('filterable', self.filterable) @@ -2084,17 +2232,6 @@ class MasterView(View): f'{permission_prefix}.create', f"Create new {model_title}") - # view - if cls.viewable: - instance_url_prefix = cls.get_instance_url_prefix() - config.add_route(f'{route_prefix}.view', instance_url_prefix) - config.add_view(cls, attr='view', - route_name=f'{route_prefix}.view', - permission=f'{permission_prefix}.view') - config.add_wutta_permission(permission_prefix, - f'{permission_prefix}.view', - f"View {model_title}") - # edit if cls.editable: instance_url_prefix = cls.get_instance_url_prefix() @@ -2119,6 +2256,18 @@ class MasterView(View): f'{permission_prefix}.delete', f"Delete {model_title}") + # bulk delete + if cls.deletable_bulk: + config.add_route(f'{route_prefix}.delete_bulk', + f'{url_prefix}/delete-bulk', + request_method='POST') + config.add_view(cls, attr='delete_bulk', + route_name=f'{route_prefix}.delete_bulk', + permission=f'{permission_prefix}.delete_bulk') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.delete_bulk', + f"Delete {model_title_plural} in bulk") + # autocomplete if cls.has_autocomplete: config.add_route(f'{route_prefix}.autocomplete', @@ -2138,3 +2287,16 @@ class MasterView(View): config.add_wutta_permission(permission_prefix, f'{permission_prefix}.configure', f"Configure {model_title_plural}") + + # view + # nb. always register this one last, so it does not take + # priority over model-wide action routes, e.g. delete_bulk + if cls.viewable: + instance_url_prefix = cls.get_instance_url_prefix() + config.add_route(f'{route_prefix}.view', instance_url_prefix) + config.add_view(cls, attr='view', + route_name=f'{route_prefix}.view', + permission=f'{permission_prefix}.view') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.view', + f"View {model_title}") diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index aa28416..43b4687 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -202,6 +202,7 @@ class SettingView(MasterView): """ model_class = Setting model_title = "Raw Setting" + deletable_bulk = True filter_defaults = { 'name': {'active': True}, } diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 840715e..f532ddf 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -254,6 +254,42 @@ class TestGrid(WebTestCase): self.assertEqual(len(grid.actions), 1) self.assertIsInstance(grid.actions[0], mod.GridAction) + def test_set_tools(self): + grid = self.make_grid() + self.assertEqual(grid.tools, {}) + + # null + grid.set_tools(None) + self.assertEqual(grid.tools, {}) + + # empty + grid.set_tools({}) + self.assertEqual(grid.tools, {}) + + # full dict is replaced + grid.tools = {'foo': 'bar'} + self.assertEqual(grid.tools, {'foo': 'bar'}) + grid.set_tools({'bar': 'baz'}) + self.assertEqual(grid.tools, {'bar': 'baz'}) + + # can specify as list of html elements + grid.set_tools(['foo', 'bar']) + self.assertEqual(len(grid.tools), 2) + self.assertEqual(list(grid.tools.values()), ['foo', 'bar']) + + def test_add_tool(self): + grid = self.make_grid() + self.assertEqual(grid.tools, {}) + + # with key + grid.add_tool('foo', key='foo') + self.assertEqual(grid.tools, {'foo': 'foo'}) + + # without key + grid.add_tool('bar') + self.assertEqual(len(grid.tools), 2) + self.assertEqual(list(grid.tools.values()), ['foo', 'bar']) + def test_get_pagesize_options(self): grid = self.make_grid() diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 2e5f8a7..0224468 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -27,6 +27,7 @@ class TestMasterView(WebTestCase): with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid', + deletable_bulk=True, has_autocomplete=True, configurable=True): mod.MasterView.defaults(self.pyramid_config) @@ -400,6 +401,33 @@ class TestMasterView(WebTestCase): self.assertTrue(view.has_any_perm('list', 'view')) self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view')) + def test_make_button(self): + view = self.make_view() + + # normal + html = view.make_button('click me') + self.assertIn(' Date: Sat, 24 Aug 2024 19:28:13 -0500 Subject: [PATCH 3/9] feat: add basic progress page/indicator support so far "delete results" (for Raw Settings) is the only use case. user cancel is not yet supported --- docs/api/wuttaweb/index.rst | 2 + docs/api/wuttaweb/progress.rst | 6 + docs/api/wuttaweb/views.people.rst | 2 +- docs/api/wuttaweb/views.progress.rst | 6 + src/wuttaweb/progress.py | 165 +++++++++++++++++++++++++++ src/wuttaweb/templates/base.mako | 57 +++++---- src/wuttaweb/templates/progress.mako | 122 ++++++++++++++++++++ src/wuttaweb/views/essential.py | 2 + src/wuttaweb/views/master.py | 117 +++++++++++++++++-- src/wuttaweb/views/progress.py | 75 ++++++++++++ tests/test_progress.py | 62 ++++++++++ tests/views/test_master.py | 108 +++++++++++++++++- tests/views/test_progress.py | 62 ++++++++++ 13 files changed, 746 insertions(+), 40 deletions(-) create mode 100644 docs/api/wuttaweb/progress.rst create mode 100644 docs/api/wuttaweb/views.progress.rst create mode 100644 src/wuttaweb/progress.py create mode 100644 src/wuttaweb/templates/progress.mako create mode 100644 src/wuttaweb/views/progress.py create mode 100644 tests/test_progress.py create mode 100644 tests/views/test_progress.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 739935b..7299034 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -20,6 +20,7 @@ handler helpers menus + progress static subscribers util @@ -30,6 +31,7 @@ views.essential views.master views.people + views.progress views.roles views.settings views.upgrades diff --git a/docs/api/wuttaweb/progress.rst b/docs/api/wuttaweb/progress.rst new file mode 100644 index 0000000..498d641 --- /dev/null +++ b/docs/api/wuttaweb/progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.progress`` +===================== + +.. automodule:: wuttaweb.progress + :members: diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst index 89c6883..2dc919b 100644 --- a/docs/api/wuttaweb/views.people.rst +++ b/docs/api/wuttaweb/views.people.rst @@ -1,6 +1,6 @@ ``wuttaweb.views.people`` -=========================== +========================= .. automodule:: wuttaweb.views.people :members: diff --git a/docs/api/wuttaweb/views.progress.rst b/docs/api/wuttaweb/views.progress.rst new file mode 100644 index 0000000..34e2661 --- /dev/null +++ b/docs/api/wuttaweb/views.progress.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.progress`` +=========================== + +.. automodule:: wuttaweb.views.progress + :members: diff --git a/src/wuttaweb/progress.py b/src/wuttaweb/progress.py new file mode 100644 index 0000000..759c2da --- /dev/null +++ b/src/wuttaweb/progress.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework 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. +# +# Wutta Framework 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 +# Wutta Framework. If not, see . +# +################################################################################ +""" +Progress Indicators +""" + +from wuttjamaican.progress import ProgressBase + +from beaker.session import Session as BeakerSession + + +def get_basic_session(request, **kwargs): + """ + Create/get a "basic" Beaker session object. + """ + kwargs['use_cookies'] = False + return BeakerSession(request, **kwargs) + + +def get_progress_session(request, key, **kwargs): + """ + Create/get a Beaker session object, to be used for progress. + """ + kwargs['id'] = f'{request.session.id}.progress.{key}' + return get_basic_session(request, **kwargs) + + +class SessionProgress(ProgressBase): + """ + Progress indicator which uses Beaker session storage to track + current status. + + This is a subclass of + :class:`wuttjamaican:wuttjamaican.progress.ProgressBase`. + + A view callable can create one of these, and then pass it into + :meth:`~wuttjamaican.app.AppHandler.progress_loop()` or similar. + + As the loop updates progress along the way, this indicator will + update the Beaker session to match. + + Separately then, the client side can send requests for the + :func:`~wuttaweb.views.progress.progress()` view, to fetch current + status out of the Beaker session. + + :param request: Current :term:`request` object. + + :param key: Unique key for this progress indicator. Used to + distinguish progress indicators in the Beaker session. + + Note that in addition to + :meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.update()` + and + :meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.finish()` + this progres class has some extra attributes and methods: + + .. attribute:: success_msg + + Optional message to display to the user (via session flash) + when the operation completes successfully. + + .. attribute:: success_url + + URL to which user should be redirected, once the operation + completes. + + .. attribute:: error_url + + URL to which user should be redirected, if the operation + encounters an error. If not specified, will fall back to + :attr:`success_url`. + """ + + def __init__(self, request, key, success_msg=None, success_url=None, error_url=None): + self.key = key + self.success_msg = success_msg + self.success_url = success_url + self.error_url = error_url or self.success_url + self.session = get_progress_session(request, key) + self.clear() + + def __call__(self, message, maximum): + self.clear() + self.session['message'] = message + self.session['maximum'] = maximum + self.session['maximum_display'] = f'{maximum:,d}' + self.session['value'] = 0 + self.session.save() + return self + + def clear(self): + """ """ + self.session.clear() + self.session['complete'] = False + self.session['error'] = False + self.session.save() + + def update(self, value): + """ """ + self.session.load() + self.session['value'] = value + self.session.save() + + def handle_error(self, error, error_url=None): + """ + This should be called by the view code, within a try/catch + block upon error. + + The session storage will be updated to reflect details of the + error. Next time client requests the progress status it will + learn of the error and redirect the user. + + :param error: :class:`python:Exception` instance. + + :param error_url: Optional redirect URL; if not specified + :attr:`error_url` is used. + """ + self.session.load() + self.session['error'] = True + self.session['error_msg'] = str(error) + self.session['error_url'] = error_url or self.error_url + self.session.save() + + def handle_success(self, success_msg=None, success_url=None): + """ + This should be called by the view code, when the long-running + operation completes. + + The session storage will be updated to reflect the completed + status. Next time client requests the progress status it will + discover it has completed, and redirect the user. + + :param success_msg: Optional message to display to the user + (via session flash) when the operation completes + successfully. If not specified :attr:`success_msg` (or + nothing) is used + + :param success_url: Optional redirect URL; if not specified + :attr:`success_url` is used. + """ + self.session.load() + self.session['complete'] = True + self.session['success_msg'] = success_msg or self.success_msg + self.session['success_url'] = success_url or self.success_url + self.session.save() diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index a85bc2d..3b3f115 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -3,13 +3,7 @@ <%namespace file="/wutta-components.mako" import="make_wutta_components" /> - - - ${base_meta.global_title()} » ${capture(self.title)|n} - ${base_meta.favicon()} - ${self.header_core()} - ${self.head_tags()} - + ${self.html_head()}
@@ -30,7 +24,20 @@ -## nb. this becomes part of the page tag within <head> +<%def name="html_head()"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${self.head_title()} + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} + + + +## nb. this is the full within html <head> +<%def name="head_title()">${base_meta.global_title()} » ${self.title()}</%def> + +## nb. this becomes part of head_title() above ## it also is used as default value for content_title() below <%def name="title()"></%def> @@ -39,9 +46,9 @@ <%def name="content_title()">${self.title()}</%def> <%def name="header_core()"> - ${self.core_javascript()} + ${self.base_javascript()} ${self.extra_javascript()} - ${self.core_styles()} + ${self.base_styles()} ${self.extra_styles()} </%def> @@ -49,6 +56,10 @@ ${self.vuejs()} ${self.buefy()} ${self.fontawesome()} +</%def> + +<%def name="base_javascript()"> + ${self.core_javascript()} ${self.hamburger_menu_js()} </%def> @@ -99,7 +110,6 @@ <%def name="core_styles()"> ${self.buefy_styles()} - ${self.base_styles()} </%def> <%def name="buefy_styles()"> @@ -107,6 +117,7 @@ </%def> <%def name="base_styles()"> + ${self.core_styles()} <style> ############################## @@ -194,16 +205,7 @@ <%def name="head_tags()"></%def> -<%def name="render_vue_template_whole_page()"> - <script type="text/x-template" id="whole-page-template"> - - ## nb. the whole-page contains 3 elements: - ## 1) header-wrapper - ## 2) content-wrapper - ## 3) footer - <div id="whole-page" - style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> - +<%def name="whole_page_content()"> ## nb. the header-wrapper contains 2 elements: ## 1) header proper (menu + index title area) ## 2) page/content title area @@ -327,7 +329,18 @@ ${base_meta.footer()} </div> </footer> +</%def> +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + + ## nb. the whole-page normally contains 3 elements: + ## 1) header-wrapper + ## 2) content-wrapper + ## 3) footer + <div id="whole-page" + style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + ${self.whole_page_content()} </div> </script> </%def> @@ -418,7 +431,7 @@ mounted() { for (let hook of this.mountedHooks) { - hook(this) + hook.call(this) } }, diff --git a/src/wuttaweb/templates/progress.mako b/src/wuttaweb/templates/progress.mako new file mode 100644 index 0000000..35223a1 --- /dev/null +++ b/src/wuttaweb/templates/progress.mako @@ -0,0 +1,122 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="head_title()">${initial_msg or "Working"}...</%def> + +<%def name="base_javascript()"> + ${self.core_javascript()} +</%def> + +<%def name="base_styles()"> + ${self.core_styles()} +</%def> + +<%def name="whole_page_content()"> + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> + + <div style="display: flex;"> + <div style="flex-grow: 1;"></div> + <div> + + <p class="block"> + {{ progressMessage }} ... {{ totalDisplay }} + </p> + + <div class="level"> + + <div class="level-item"> + <b-progress size="is-large" + style="width: 400px;" + :max="progressMax" + :value="progressValue" + show-value + format="percent" + precision="0"> + </b-progress> + </div> + + </div> + + </div> + <div style="flex-grow: 1;"></div> + </div> + + ${self.after_progress()} + + </div> + </div> + </section> +</%def> + +<%def name="after_progress()"></%def> + +<%def name="modify_vue_vars()"> + <script> + + WholePageData.progressURL = '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}' + WholePageData.progressMessage = "${(initial_msg or "Working").replace('"', '\\"')} (please wait)" + WholePageData.progressMax = null + WholePageData.progressMaxDisplay = null + WholePageData.progressValue = null + WholePageData.stillInProgress = true + + WholePage.computed.totalDisplay = function() { + + if (!this.stillInProgress) { + return "done!" + } + + if (this.progressMaxDisplay) { + return `(${'$'}{this.progressMaxDisplay} total)` + } + } + + WholePageData.mountedHooks.push(function() { + + // fetch first progress data, one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + }) + + WholePage.methods.updateProgress = function() { + + this.$http.get(this.progressURL).then(response => { + + if (response.data.error) { + // errors stop the show; redirect + location.href = response.data.error_url + + } else { + + if (response.data.complete || response.data.maximum) { + this.progressMessage = response.data.message + this.progressMaxDisplay = response.data.maximum_display + + if (response.data.complete) { + this.progressValue = this.progressMax + this.stillInProgress = false + + location.href = response.data.success_url + + } else { + this.progressValue = response.data.value + this.progressMax = response.data.maximum + } + } + + if (this.stillInProgress) { + + // fetch progress data again, in one second from now + setTimeout(() => { + this.updateProgress() + }, 1000) + } + } + }) + } + + </script> +</%def> diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index b6a05b1..56c669b 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -32,6 +32,7 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.auth` * :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.settings` +* :mod:`wuttaweb.views.progress` * :mod:`wuttaweb.views.people` * :mod:`wuttaweb.views.roles` * :mod:`wuttaweb.views.users` @@ -45,6 +46,7 @@ def defaults(config, **kwargs): config.include(mod('wuttaweb.views.auth')) config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.settings')) + config.include(mod('wuttaweb.views.progress')) config.include(mod('wuttaweb.views.people')) config.include(mod('wuttaweb.views.roles')) config.include(mod('wuttaweb.views.users')) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index d1e9bef..7477ec2 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -25,6 +25,7 @@ Base Logic for Master Views """ import logging +import threading import sqlalchemy as sa from sqlalchemy import orm @@ -35,6 +36,7 @@ from webhelpers2.html import HTML from wuttaweb.views import View from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token from wuttaweb.db import Session +from wuttaweb.progress import SessionProgress from wuttjamaican.util import get_class_hierarchy @@ -295,6 +297,19 @@ class MasterView(View): deleting" - i.e. it should have a :meth:`delete_bulk()` view. Default value is ``False``. + See also :attr:`deletable_bulk_quick`. + + .. attribute:: deletable_bulk_quick + + Boolean indicating whether the view model supports "quick" bulk + deleting, i.e. the operation is reliably quick enough that it + should happen *synchronously* with no progress indicator. + + Default is ``False`` in which case a progress indicator is + shown while the bulk deletion is performed. + + Only relevant if :attr:`deletable_bulk` is true. + .. attribute:: form_fields List of fields for the model form. @@ -333,6 +348,7 @@ class MasterView(View): editable = True deletable = True deletable_bulk = False + deletable_bulk_quick = False has_autocomplete = False configurable = False @@ -634,7 +650,7 @@ class MasterView(View): session = self.app.get_session(obj) session.delete(obj) - def delete_bulk(self, session=None): + def delete_bulk(self): """ View to delete all records in the current :meth:`index()` grid data set, i.e. those matching current query. @@ -652,35 +668,79 @@ class MasterView(View): one of the related methods which are called (in)directly by this one: - * :meth:`delete_bulk_data()` + * :meth:`delete_bulk_action()` """ + # get current data set from grid # nb. this must *not* be paginated, we need it all grid = self.make_model_grid(paginated=False) data = grid.get_visible_data() - # delete it all and go back to listing - self.delete_bulk_data(data, session=session) - return self.redirect(self.get_index_url()) + if self.deletable_bulk_quick: - def delete_bulk_data(self, data, session=None): + # delete it all and go back to listing + self.delete_bulk_action(data) + return self.redirect(self.get_index_url()) + + else: + + # start thread for delete; show progress page + route_prefix = self.get_route_prefix() + key = f'{route_prefix}.delete_bulk' + progress = self.make_progress(key, success_url=self.get_index_url()) + thread = threading.Thread(target=self.delete_bulk_thread, + args=(data,), kwargs={'progress': progress}) + thread.start() + return self.render_progress(progress) + + def delete_bulk_thread(self, query, success_url=None, progress=None): + """ """ + model_title_plural = self.get_model_title_plural() + + # nb. use new session, separate from web transaction + session = self.app.make_session() + records = query.with_session(session).all() + + try: + self.delete_bulk_action(records, progress=progress) + + except Exception as error: + session.rollback() + log.warning("failed to delete %s results for %s", + len(records), model_title_plural, + exc_info=True) + if progress: + progress.handle_error(error, "Bulk deletion failed") + + else: + session.commit() + if progress: + progress.handle_success() + + finally: + session.close() + + def delete_bulk_action(self, data, progress=None): """ This method performs the actual bulk deletion, for the given - data set. + data set. This is called via :meth:`delete_bulk()`. Default logic will call :meth:`is_deletable()` for every data record, and if that returns true then it calls - :meth:`delete_instance()`. + :meth:`delete_instance()`. A progress indicator will be + updated if one is provided. - As of now there is no progress indicator or async; caller must - simply wait until delete is finished. + Subclass should override if needed. """ - session = session or self.Session() + model_title_plural = self.get_model_title_plural() - for obj in data: + def delete(obj, i): if self.is_deletable(obj): self.delete_instance(obj) + self.app.progress_loop(delete, data, progress, + message=f"Deleting {model_title_plural}") + def delete_bulk_make_button(self): """ """ route_prefix = self.get_route_prefix() @@ -1308,6 +1368,39 @@ class MasterView(View): return HTML.tag('b-button', **btn_kw) + def make_progress(self, key, **kwargs): + """ + Create and return a + :class:`~wuttaweb.progress.SessionProgress` instance, with the + given key. + + This is normally done just before calling + :meth:`render_progress()`. + """ + return SessionProgress(self.request, key, **kwargs) + + def render_progress(self, progress, context=None, template=None): + """ + Render the progress page, with given template/context. + + When a view method needs to start a long-running operation, it + first starts a thread to do the work, and then it renders the + "progress" page. As the operation continues the progress page + is updated. When the operation completes (or fails) the user + is redirected to the final destination. + + TODO: should document more about how to do this.. + + :param progress: Progress indicator instance as returned by + :meth:`make_progress()`. + + :returns: A :term:`response` with rendered progress page. + """ + template = template or '/progress.mako' + context = context or {} + context['progress'] = progress + return render_to_response(template, context, request=self.request) + def render_to_response(self, template, context): """ Locate and render an appropriate template, with the given diff --git a/src/wuttaweb/views/progress.py b/src/wuttaweb/views/progress.py new file mode 100644 index 0000000..a06ebf2 --- /dev/null +++ b/src/wuttaweb/views/progress.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework 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. +# +# Wutta Framework 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 +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Progress Views +""" + +from wuttaweb.progress import get_progress_session + + +def progress(request): + """ + View which returns JSON with current progress status. + + The URL is like ``/progress/XXX`` where ``XXX`` is the "key" to a + particular progress indicator, tied to a long-running operation. + + This key is used to lookup the progress status within the Beaker + session storage. See also + :class:`~wuttaweb.progress.SessionProgress`. + """ + key = request.matchdict['key'] + session = get_progress_session(request, key) + + # session has 'complete' flag set when operation is over + if session.get('complete'): + + # set a flash msg for user if one is defined. this is the + # time to do it since user is about to get redirected. + msg = session.get('success_msg') + if msg: + request.session.flash(msg) + + elif session.get('error'): # uh-oh + + # set an error flash msg for user. this is the time to do it + # since user is about to get redirected. + msg = session.get('error_msg', "An unspecified error occurred.") + request.session.flash(msg, 'error') + + # nb. we return the session as-is; since it is dict-like (and only + # contains relevant progress data) it can be used directly for the + # JSON response context + return session + + +def defaults(config, **kwargs): + base = globals() + + progress = kwargs.get('progress', base['progress']) + config.add_route('progress', '/progress/{key}') + config.add_view(progress, route_name='progress', renderer='json') + + +def includeme(config): + defaults(config) diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..8bfce3c --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from pyramid import testing +from beaker.session import Session as BeakerSession + +from wuttaweb import progress as mod + + +class TestGetBasicSession(TestCase): + + def setUp(self): + self.request = testing.DummyRequest() + + def test_basic(self): + session = mod.get_basic_session(self.request) + self.assertIsInstance(session, BeakerSession) + self.assertFalse(session.use_cookies) + + +class TestGetProgressSession(TestCase): + + def setUp(self): + self.request = testing.DummyRequest() + + def test_basic(self): + self.request.session.id = 'mockid' + session = mod.get_progress_session(self.request, 'foo') + self.assertIsInstance(session, BeakerSession) + self.assertEqual(session.id, 'mockid.progress.foo') + + +class TestSessionProgress(TestCase): + + def setUp(self): + self.request = testing.DummyRequest() + self.request.session.id = 'mockid' + + def test_error_url(self): + factory = mod.SessionProgress(self.request, 'foo', success_url='/blart') + self.assertEqual(factory.error_url, '/blart') + + def test_basic(self): + + # sanity / coverage check + factory = mod.SessionProgress(self.request, 'foo') + prog = factory("doing things", 2) + prog.update(1) + prog.update(2) + prog.handle_success() + + def test_error(self): + + # sanity / coverage check + factory = mod.SessionProgress(self.request, 'foo') + prog = factory("doing things", 2) + prog.update(1) + try: + raise RuntimeError('omg') + except Exception as error: + prog.handle_error(error) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 0224468..6fdb55d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -14,6 +14,7 @@ from pyramid.httpexceptions import HTTPNotFound from wuttjamaican.conf import WuttaConfig from wuttaweb.views import master as mod from wuttaweb.views import View +from wuttaweb.progress import SessionProgress from wuttaweb.subscribers import new_request_set_user from tests.util import WebTestCase @@ -428,6 +429,22 @@ class TestMasterView(WebTestCase): self.assertIn('click me', html) self.assertIn('is-primary', html) + def test_make_progress(self): + + # basic + view = self.make_view() + self.request.session.id = 'mockid' + progress = view.make_progress('foo') + self.assertIsInstance(progress, SessionProgress) + + def test_render_progress(self): + self.pyramid_config.add_route('progress', '/progress/{key}') + + # sanity / coverage check + view = self.make_view() + progress = MagicMock() + response = view.render_progress(progress) + def test_render_to_response(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') @@ -1098,6 +1115,7 @@ class TestMasterView(WebTestCase): def test_delete_bulk(self): self.pyramid_config.add_route('settings', '/settings/') + self.pyramid_config.add_route('progress', '/progress/{key}') model = self.app.model sample_data = [ {'name': 'foo1', 'value': 'ONE'}, @@ -1132,13 +1150,35 @@ class TestMasterView(WebTestCase): data = grid.get_visible_data() self.assertEqual(len(data), 2) - # okay now let's delete those (gets redirected) - with patch.object(view, 'make_model_grid', return_value=grid): - response = view.delete_bulk(session=self.session) + # okay now let's delete those via quick method + # (user should be redirected back to index) + with patch.multiple(view, + deletable_bulk_quick=True, + make_model_grid=MagicMock(return_value=grid)): + response = view.delete_bulk() self.assertEqual(response.status_code, 302) self.assertEqual(self.session.query(model.Setting).count(), 7) - def test_delete_bulk_data(self): + # now use another filter since those records are gone + self.request.GET = {'name': 'foo2', 'name.verb': 'equal'} + grid = view.make_model_grid(session=self.session) + self.assertEqual(len(grid.filters), 2) + self.assertEqual(len(grid.active_filters), 1) + data = grid.get_visible_data() + self.assertEqual(len(data), 1) + + # this time we delete "slowly" with progress + self.request.session.id = 'ignorethis' + with patch.multiple(view, + deletable_bulk_quick=False, + make_model_grid=MagicMock(return_value=grid)): + with patch.object(mod, 'threading') as threading: + response = view.delete_bulk() + threading.Thread.return_value.start.assert_called_once_with() + # nb. user is shown progress page + self.assertEqual(response.status_code, 200) + + def test_delete_bulk_action(self): self.pyramid_config.add_route('settings', '/settings/') model = self.app.model sample_data = [ @@ -1167,10 +1207,68 @@ class TestMasterView(WebTestCase): .filter(model.Setting.value.ilike('%s%'))\ .all() self.assertEqual(len(settings), 2) - view.delete_bulk_data(settings, session=self.session) + view.delete_bulk_action(settings) self.session.commit() self.assertEqual(self.session.query(model.Setting).count(), 7) + def test_delete_bulk_thread(self): + self.pyramid_config.add_route('settings', '/settings/') + 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) + + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting): + view = self.make_view() + + # basic delete, no progress + self.assertEqual(self.session.query(model.Setting).count(), 9) + settings = self.session.query(model.Setting)\ + .filter(model.Setting.value.ilike('%s%')) + self.assertEqual(settings.count(), 2) + with patch.object(self.app, 'make_session', return_value=self.session): + view.delete_bulk_thread(settings) + self.assertEqual(self.session.query(model.Setting).count(), 7) + + # basic delete, with progress + settings = self.session.query(model.Setting)\ + .filter(model.Setting.name == 'foo1') + self.assertEqual(settings.count(), 1) + with patch.object(self.app, 'make_session', return_value=self.session): + view.delete_bulk_thread(settings, progress=MagicMock()) + self.assertEqual(self.session.query(model.Setting).count(), 6) + + # error, no progress + settings = self.session.query(model.Setting)\ + .filter(model.Setting.name == 'foo2') + self.assertEqual(settings.count(), 1) + with patch.object(self.app, 'make_session', return_value=self.session): + with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError): + view.delete_bulk_thread(settings) + # nb. nothing was deleted + self.assertEqual(self.session.query(model.Setting).count(), 6) + + # error, with progress + self.assertEqual(settings.count(), 1) + with patch.object(self.app, 'make_session', return_value=self.session): + with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError): + view.delete_bulk_thread(settings, progress=MagicMock()) + # nb. nothing was deleted + self.assertEqual(self.session.query(model.Setting).count(), 6) + def test_autocomplete(self): model = self.app.model diff --git a/tests/views/test_progress.py b/tests/views/test_progress.py new file mode 100644 index 0000000..06a67f8 --- /dev/null +++ b/tests/views/test_progress.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8; -*- + +from pyramid import testing + +from wuttaweb.views import progress as mod +from wuttaweb.progress import get_progress_session +from tests.util import WebTestCase + + +class TestProgressView(WebTestCase): + + def test_includeme(self): + self.pyramid_config.include('wuttaweb.views.progress') + + def test_basic(self): + self.request.session.id = 'mockid' + self.request.matchdict = {'key': 'foo'} + + # first call with no setup, will create the progress session + # but it should be "empty" - except not really since beaker + # adds some keys by default + context = mod.progress(self.request) + self.assertIsInstance(context, dict) + + # now let's establish a progress session of our own + progsess = get_progress_session(self.request, 'bar') + progsess['maximum'] = 2 + progsess['value'] = 1 + progsess.save() + + # then call view, check results + self.request.matchdict = {'key': 'bar'} + context = mod.progress(self.request) + self.assertEqual(context['maximum'], 2) + self.assertEqual(context['value'], 1) + self.assertNotIn('complete', context) + + # now mark it as complete, check results + progsess['complete'] = True + progsess['success_msg'] = "yay!" + progsess.save() + context = mod.progress(self.request) + self.assertTrue(context['complete']) + self.assertEqual(context['success_msg'], "yay!") + + # now do that all again, with error + progsess = get_progress_session(self.request, 'baz') + progsess['maximum'] = 2 + progsess['value'] = 1 + progsess.save() + self.request.matchdict = {'key': 'baz'} + context = mod.progress(self.request) + self.assertEqual(context['maximum'], 2) + self.assertEqual(context['value'], 1) + self.assertNotIn('complete', context) + self.assertNotIn('error', context) + progsess['error'] = True + progsess['error_msg'] = "omg!" + progsess.save() + context = mod.progress(self.request) + self.assertTrue(context['error']) + self.assertEqual(context['error_msg'], "omg!") From e5e31a7d325048351f18c6ce6a306f45ce898079 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Aug 2024 12:20:28 -0500 Subject: [PATCH 4/9] feat: add basic support for execute upgrades, download stdout/stderr upgrade progress is still not being shown yet --- docs/conf.py | 1 + pyproject.toml | 1 + src/wuttaweb/forms/schema.py | 32 ++++ src/wuttaweb/forms/widgets.py | 66 ++++++- .../templates/deform/readonly/filedownload.pt | 14 ++ .../templates/upgrades/configure.mako | 20 ++ src/wuttaweb/templates/upgrades/view.mako | 37 ++++ src/wuttaweb/views/base.py | 42 ++++- src/wuttaweb/views/common.py | 3 + src/wuttaweb/views/master.py | 158 ++++++++++++++++ src/wuttaweb/views/upgrades.py | 119 +++++++++++- tests/forms/test_schema.py | 15 ++ tests/forms/test_widgets.py | 53 +++++- tests/util.py | 6 +- tests/views/test_base.py | 20 ++ tests/views/test_master.py | 55 ++++++ tests/views/test_upgrades.py | 175 +++++++++++++++++- 17 files changed, 805 insertions(+), 12 deletions(-) create mode 100644 src/wuttaweb/templates/deform/readonly/filedownload.pt create mode 100644 src/wuttaweb/templates/upgrades/configure.mako create mode 100644 src/wuttaweb/templates/upgrades/view.mako diff --git a/docs/conf.py b/docs/conf.py index 3d568ef..0f73d82 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ intersphinx_mapping = { 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), 'python': ('https://docs.python.org/3/', None), + 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } diff --git a/pyproject.toml b/pyproject.toml index 41fda6b..a671cb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", + "humanize", "paginate", "paginate_sqlalchemy", "pyramid>=2", diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 8538eef..74839a7 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -456,3 +456,35 @@ class Permissions(WuttaSet): kwargs['values'] = values return widgets.PermissionsWidget(self.request, **kwargs) + + +class FileDownload(colander.String): + """ + Custom schema type for a file download field. + + This field is only meant for readonly use, it does not handle file + uploads. + + It expects the incoming ``appstruct`` to be the path to a file on + disk (or null). + + Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by + default. + + :param request: Current :term:`request` object. + + :param url: Optional URL for hyperlink. If not specified, file + name/size is shown with no hyperlink. + """ + + def __init__(self, request, *args, **kwargs): + self.url = kwargs.pop('url', None) + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def widget_maker(self, **kwargs): + """ """ + kwargs.setdefault('url', self.url) + return widgets.FileDownloadWidget(self.request, **kwargs) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 837b6f1..4db861a 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -39,7 +39,10 @@ in the namespace: * :class:`deform:deform.widget.MoneyInputWidget` """ +import os + import colander +import humanize from deform.widget import (Widget, TextInputWidget, TextAreaWidget, PasswordWidget, CheckedPasswordWidget, CheckboxWidget, SelectWidget, CheckboxChoiceWidget, @@ -147,6 +150,63 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): self.session = session or Session() +class FileDownloadWidget(Widget): + """ + Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` + fields. + + This only supports readonly, and shows a hyperlink to download the + file. Link text is the filename plus file size. + + This is a subclass of :class:`deform:deform.widget.Widget` and + uses these Deform templates: + + * ``readonly/filedownload`` + + :param request: Current :term:`request` object. + + :param url: Optional URL for hyperlink. If not specified, file + name/size is shown with no hyperlink. + """ + readonly_template = 'readonly/filedownload' + + def __init__(self, request, *args, **kwargs): + self.url = kwargs.pop('url', None) + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def serialize(self, field, cstruct, **kw): + """ """ + # nb. readonly is the only way this rolls + kw['readonly'] = True + template = self.readonly_template + + path = cstruct or None + if path: + kw.setdefault('filename', os.path.basename(path)) + kw.setdefault('filesize', self.readable_size(path)) + if self.url: + kw.setdefault('url', self.url) + + else: + kw.setdefault('filename', None) + kw.setdefault('filesize', None) + + kw.setdefault('url', None) + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def readable_size(self, path): + """ """ + try: + size = os.path.getsize(path) + except os.error: + size = 0 + return humanize.naturalsize(size) + + class RoleRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with User @@ -184,7 +244,7 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): roles = [] if cstruct: for uuid in cstruct: - role = self.session.query(model.Role).get(uuid) + role = self.session.get(model.Role, uuid) if role: roles.append(role) kw['roles'] = roles @@ -228,6 +288,10 @@ class UserRefsWidget(WuttaCheckboxChoiceWidget): users.append(dict([(key, getattr(user, key)) for key in columns + ['uuid']])) + # do not render if no data + if not users: + return HTML.tag('span') + # grid grid = Grid(self.request, key='roles.view.users', columns=columns, data=users) diff --git a/src/wuttaweb/templates/deform/readonly/filedownload.pt b/src/wuttaweb/templates/deform/readonly/filedownload.pt new file mode 100644 index 0000000..31a789b --- /dev/null +++ b/src/wuttaweb/templates/deform/readonly/filedownload.pt @@ -0,0 +1,14 @@ +<tal:omit> + <a tal:condition="url" href="${url}"> + ${filename} + <tal:omit tal:condition="filesize"> + (${filesize}) + </tal:omit> + </a> + <span tal:condition="not url"> + ${filename} + <tal:omit tal:condition="filesize"> + (${filesize}) + </tal:omit> + </span> +</tal:omit> diff --git a/src/wuttaweb/templates/upgrades/configure.mako b/src/wuttaweb/templates/upgrades/configure.mako new file mode 100644 index 0000000..2e4eae1 --- /dev/null +++ b/src/wuttaweb/templates/upgrades/configure.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="is-size-3">Basics</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field label="Upgrade Script (for Execute)" + message="The command + args will be interpreted by the shell."> + <b-input name="${app.appname}.upgrades.command" + v-model="simpleSettings['${app.appname}.upgrades.command']" + @input="settingsNeedSaved = true" + ## ref="upgradeSystemCommand" + ## expanded + /> + </b-field> + + </div> +</%def> diff --git a/src/wuttaweb/templates/upgrades/view.mako b/src/wuttaweb/templates/upgrades/view.mako new file mode 100644 index 0000000..4794641 --- /dev/null +++ b/src/wuttaweb/templates/upgrades/view.mako @@ -0,0 +1,37 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + % if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'): + <div class="buttons" + style="margin: 2rem 5rem;"> + + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'executeFormSubmit'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="executeFormSubmitting"> + {{ executeFormSubmitting ? "Working, please wait..." : "Execute this upgrade" }} + </b-button> + ${h.end_form()} + </div> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'): + <script> + + ThisPageData.executeFormSubmitting = false + + ThisPage.methods.executeFormSubmit = function() { + this.executeFormSubmitting = true + } + + </script> + % endif +</%def> diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index bc1e76c..5121f3c 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -24,8 +24,11 @@ Base Logic for Views """ +import os + from pyramid import httpexceptions from pyramid.renderers import render_to_response +from pyramid.response import FileResponse from wuttaweb import forms, grids @@ -119,9 +122,46 @@ class View: """ return httpexceptions.HTTPFound(location=url, **kwargs) + def file_response(self, path, attachment=True, filename=None): + """ + Returns a generic file response for the given path. + + :param path: Path to a file on local disk; must be accessible + by the web app. + + :param attachment: Whether the file should come down as an + "attachment" instead of main payload. + + The attachment behavior is the default here, and will cause + the user to be prompted for where to save the file. + + Set ``attachment=False`` in order to cause the browser to + render the file as if it were the page being navigated to. + + :param filename: Optional filename to use for attachment + behavior. This will be the "suggested filename" when user + is prompted to save the download. If not specified, the + filename is derived from ``path``. + + :returns: A :class:`~pyramid:pyramid.response.FileResponse` + object with file content. + """ + if not os.path.exists(path): + return self.notfound() + + response = FileResponse(path, request=self.request) + response.content_length = os.path.getsize(path) + + if attachment: + if not filename: + filename = os.path.basename(path) + response.content_disposition = f'attachment; filename="{filename}"' + + return response + def json_response(self, context): """ - Convenience method to return a JSON response. + Returns a JSON response with the given context data. :param context: Context data to be rendered as JSON. diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index 8c6dc25..279c61a 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -160,6 +160,9 @@ class CommonView(View): 'upgrades.view', 'upgrades.edit', 'upgrades.delete', + 'upgrades.execute', + 'upgrades.download', + 'upgrades.configure', 'users.list', 'users.create', 'users.view', diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 7477ec2..fe333b1 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -25,6 +25,7 @@ Base Logic for Master Views """ import logging +import os import threading import sqlalchemy as sa @@ -322,6 +323,18 @@ class MasterView(View): "autocomplete" - i.e. it should have an :meth:`autocomplete()` view. Default is ``False``. + .. attribute:: downloadable + + Boolean indicating whether the view model supports + "downloading" - i.e. it should have a :meth:`download()` view. + Default is ``False``. + + .. attribute:: executable + + Boolean indicating whether the view model supports "executing" + - i.e. it should have an :meth:`execute()` view. Default is + ``False``. + .. attribute:: configurable Boolean indicating whether the master view supports @@ -350,6 +363,8 @@ class MasterView(View): deletable_bulk = False deletable_bulk_quick = False has_autocomplete = False + downloadable = False + executable = False configurable = False # current action @@ -842,6 +857,126 @@ class MasterView(View): 'label': str(obj), } + ############################## + # download methods + ############################## + + def download(self): + """ + View to download a file associated with a model record. + + This usually corresponds to a URL like + ``/widgets/XXX/download`` where ``XXX`` represents the key/ID + for the record. + + By default, this view is included only if :attr:`downloadable` + is true. + + This method will (try to) locate the file on disk, and return + it as a file download response to the client. + + The GET request for this view may contain a ``filename`` query + string parameter, which can be used to locate one of various + files associated with the model record. This filename is + passed to :meth:`download_path()` for locating the file. + + For instance: ``/widgets/XXX/download?filename=widget-specs.txt`` + + Subclass normally should not override this method, but rather + one of the related methods which are called (in)directly by + this one: + + * :meth:`download_path()` + """ + obj = self.get_instance() + filename = self.request.GET.get('filename', None) + + path = self.download_path(obj, filename) + if not path or not os.path.exists(path): + return self.notfound() + + return self.file_response(path) + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and + filename. Result will be used to return a file response to + client. This is called by :meth:`download()`. + + Default logic always returns ``None``; subclass must override. + + :param obj: Refefence to the model instance. + + :param filename: Name of file for which to retrieve the path. + + :returns: Path to file, or ``None`` if not found. + + Note that ``filename`` may be ``None`` in which case the "default" + file path should be returned, if applicable. + + If this method returns ``None`` (as it does by default) then + the :meth:`download()` view will return a 404 not found + response. + """ + + ############################## + # execute methods + ############################## + + def execute(self): + """ + View to "execute" a model record. Requires a POST request. + + This usually corresponds to a URL like + ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID + for the record. + + By default, this view is included only if :attr:`executable` is + true. + + Probably this is a "rare" view to implement for a model. But + there are two notable use cases so far, namely: + + * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`) + * batches (not yet implemented; + cf. :doc:`rattail-manual:data/batch/index` in Rattail + Manual) + + The general idea is to take some "irrevocable" action + associated with the model record. In the case of upgrades, it + is to run the upgrade script. For batches it is to "push + live" the data held within the batch. + + Subclass normally should not override this method, but rather + one of the related methods which are called (in)directly by + this one: + + * :meth:`execute_instance()` + """ + model_title = self.get_model_title() + obj = self.get_instance() + + try: + self.execute_instance(obj) + except Exception as error: + log.exception("failed to execute %s: %s", model_title, obj) + error = str(error) or error.__class__.__name__ + self.request.session.flash(error, 'error') + else: + self.request.session.flash(f"{model_title} was executed.") + + return self.redirect(self.get_action_url('view', obj)) + + def execute_instance(self, obj): + """ + Perform the actual "execution" logic for a model record. + Called by :meth:`execute()`. + + This method does nothing by default; subclass must override. + + :param obj: Reference to the model instance. + """ + ############################## # configure methods ############################## @@ -2370,6 +2505,29 @@ class MasterView(View): renderer='json', permission=f'{route_prefix}.list') + # download + if cls.downloadable: + config.add_route(f'{route_prefix}.download', + f'{instance_url_prefix}/download') + config.add_view(cls, attr='download', + route_name=f'{route_prefix}.download', + permission=f'{permission_prefix}.download') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.download', + f"Download file(s) for {model_title}") + + # execute + if cls.executable: + config.add_route(f'{route_prefix}.execute', + f'{instance_url_prefix}/execute', + request_method='POST') + config.add_view(cls, attr='execute', + route_name=f'{route_prefix}.execute', + permission=f'{permission_prefix}.execute') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.execute', + f"Execute {model_title}") + # configure if cls.configurable: config.add_route(f'{route_prefix}.configure', diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py index 8d7c48c..ee4a2bd 100644 --- a/src/wuttaweb/views/upgrades.py +++ b/src/wuttaweb/views/upgrades.py @@ -24,12 +24,21 @@ Upgrade Views """ +import datetime +import logging +import os +import shutil +import subprocess + from sqlalchemy import orm from wuttjamaican.db.model import Upgrade from wuttaweb.views import MasterView from wuttaweb.forms import widgets -from wuttaweb.forms.schema import UserRef, WuttaEnum +from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload + + +log = logging.getLogger(__name__) class UpgradeView(MasterView): @@ -47,6 +56,9 @@ class UpgradeView(MasterView): * ``/upgrades/XXX/delete`` """ model_class = Upgrade + executable = True + downloadable = True + configurable = True grid_columns = [ 'created', @@ -81,6 +93,9 @@ class UpgradeView(MasterView): # status g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) + # executed + g.set_renderer('executed', self.grid_render_datetime) + # executed_by g.set_link('executed_by') Executor = orm.aliased(model.User) @@ -138,10 +153,6 @@ class UpgradeView(MasterView): else: f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) - # exit_code - if self.creating or not upgrade.executed: - f.remove('exit_code') - # executed if self.creating or self.editing or not upgrade.executed: f.remove('executed') @@ -152,6 +163,39 @@ class UpgradeView(MasterView): else: f.set_node('executed_by', UserRef(self.request)) + # exit_code + if self.creating or self.editing or not upgrade.executed: + f.remove('exit_code') + + # stdout / stderr + if not (self.creating or self.editing) and upgrade.status in ( + enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE): + + # stdout_file + f.append('stdout_file') + f.set_label('stdout_file', "STDOUT") + url = self.get_action_url('download', upgrade, _query={'filename': 'stdout.log'}) + f.set_node('stdout_file', FileDownload(self.request, url=url)) + f.set_default('stdout_file', self.get_upgrade_filepath(upgrade, 'stdout.log')) + + # stderr_file + f.append('stderr_file') + f.set_label('stderr_file', "STDERR") + url = self.get_action_url('download', upgrade, _query={'filename': 'stderr.log'}) + f.set_node('stderr_file', FileDownload(self.request, url=url)) + f.set_default('stderr_file', self.get_upgrade_filepath(upgrade, 'stderr.log')) + + def delete_instance(self, upgrade): + """ + We override this method to delete any files associated with + the upgrade, in addition to deleting the upgrade proper. + """ + path = self.get_upgrade_filepath(upgrade, create=False) + if os.path.exists(path): + shutil.rmtree(path) + + super().delete_instance(upgrade) + def objectify(self, form): """ """ upgrade = super().objectify(form) @@ -164,6 +208,71 @@ class UpgradeView(MasterView): return upgrade + def download_path(self, upgrade, filename): + """ """ + if filename: + return self.get_upgrade_filepath(upgrade, filename) + + def get_upgrade_filepath(self, upgrade, filename=None, create=True): + """ """ + uuid = upgrade.uuid + path = self.app.get_appdir('data', 'upgrades', uuid[:2], uuid[2:], + create=create) + if filename: + path = os.path.join(path, filename) + return path + + def execute_instance(self, upgrade): + """ + This method runs the actual upgrade. + + Default logic will get the script command from config, and run + it via shell in a subprocess. + + The ``stdout`` and ``stderr`` streams are captured to separate + log files which are then available to download. + + The upgrade itself is marked as "executed" with status of + either ``SUCCESS`` or ``FAILURE``. + """ + enum = self.app.enum + script = self.config.require(f'{self.app.appname}.upgrades.command', + session=self.Session()) + stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log') + stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log') + + # run the command + log.debug("running upgrade command: %s", script) + with open(stdout_path, 'wb') as stdout: + with open(stderr_path, 'wb') as stderr: + upgrade.exit_code = subprocess.call(script, shell=True, + stdout=stdout, stderr=stderr) + logger = log.warning if upgrade.exit_code != 0 else log.debug + logger("upgrade command had non-zero exit code: %s", upgrade.exit_code) + + # declare it complete + upgrade.executed = datetime.datetime.now() + upgrade.executed_by = self.request.user + if upgrade.exit_code == 0: + upgrade.status = enum.UpgradeStatus.SUCCESS + else: + upgrade.status = enum.UpgradeStatus.FAILURE + + def configure_get_simple_settings(self): + """ """ + + script = self.config.get(f'{self.app.appname}.upgrades.command') + if not script: + pass + + return [ + + # basics + {'name': f'{self.app.appname}.upgrades.command', + 'default': script}, + + ] + @classmethod def defaults(cls, config): """ """ diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 9397e03..1c7680a 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -316,3 +316,18 @@ class TestPermissions(DataTestCase): widget = typ.widget_maker() self.assertEqual(len(widget.values), 1) self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets")) + + +class TestFileDownload(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = testing.DummyRequest(wutta_config=self.config) + + def test_widget_maker(self): + + # sanity / coverage check + typ = mod.FileDownload(self.request, url='/foo') + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.FileDownloadWidget) + self.assertEqual(widget.url, '/foo') diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 62d9f0b..cfa4530 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -7,7 +7,7 @@ import deform from pyramid import testing from wuttaweb.forms import widgets as mod -from wuttaweb.forms.schema import PersonRef, RoleRefs, UserRefs, Permissions +from wuttaweb.forms.schema import FileDownload, PersonRef, RoleRefs, UserRefs, Permissions from tests.util import WebTestCase @@ -52,6 +52,55 @@ class TestObjectRefWidget(WebTestCase): self.assertIn('href="/foo"', html) +class TestFileDownloadWidget(WebTestCase): + + def make_field(self, node, **kwargs): + # TODO: not sure why default renderer is in use even though + # pyramid_deform was included in setup? but this works.. + kwargs.setdefault('renderer', deform.Form.default_renderer) + return deform.Field(node, **kwargs) + + def test_serialize(self): + + # nb. we let the field construct the widget via our type + # (nb. at first we do not provide a url) + node = colander.SchemaNode(FileDownload(self.request)) + field = self.make_field(node) + widget = field.widget + + # null value + html = widget.serialize(field, None, readonly=True) + self.assertNotIn('<a ', html) + self.assertIn('<span>', html) + + # path to nonexistent file + html = widget.serialize(field, '/this/path/does/not/exist', readonly=True) + self.assertNotIn('<a ', html) + self.assertIn('<span>', html) + + # path to actual file + datfile = self.write_file('data.txt', "hello\n" * 1000) + html = widget.serialize(field, datfile, readonly=True) + self.assertNotIn('<a ', html) + self.assertIn('<span>', html) + self.assertIn('data.txt', html) + self.assertIn('kB)', html) + + # path to file, w/ url + node = colander.SchemaNode(FileDownload(self.request, url='/download/blarg')) + field = self.make_field(node) + widget = field.widget + html = widget.serialize(field, datfile, readonly=True) + self.assertNotIn('<span>', html) + self.assertIn('<a href="/download/blarg">', html) + self.assertIn('data.txt', html) + self.assertIn('kB)', html) + + # nb. same readonly output even if we ask for editable + html2 = widget.serialize(field, datfile, readonly=False) + self.assertEqual(html2, html) + + class TestRoleRefsWidget(WebTestCase): def make_field(self, node, **kwargs): @@ -113,7 +162,7 @@ class TestUserRefsWidget(WebTestCase): # empty html = widget.serialize(field, set(), readonly=True) - self.assertIn('<b-table ', html) + self.assertEqual(html, '<span></span>') # with data, no actions user = model.User(username='barney') diff --git a/tests/util.py b/tests/util.py index ab31dd4..51a5768 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,11 +6,12 @@ from unittest.mock import MagicMock from pyramid import testing from wuttjamaican.conf import WuttaConfig +from wuttjamaican.testing import FileConfigTestCase from wuttaweb import subscribers from wuttaweb.menus import MenuHandler -class DataTestCase(TestCase): +class DataTestCase(FileConfigTestCase): """ Base class for test suites requiring a full (typical) database. """ @@ -19,6 +20,7 @@ class DataTestCase(TestCase): self.setup_db() def setup_db(self): + self.setup_files() self.config = WuttaConfig(defaults={ 'wutta.db.default.url': 'sqlite://', }) @@ -33,7 +35,7 @@ class DataTestCase(TestCase): self.teardown_db() def teardown_db(self): - pass + self.teardown_files() class WebTestCase(DataTestCase): diff --git a/tests/views/test_base.py b/tests/views/test_base.py index f86fc8f..9601212 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -50,6 +50,26 @@ class TestView(WebTestCase): self.assertIsInstance(error, HTTPFound) self.assertEqual(error.location, '/') + def test_file_response(self): + view = self.make_view() + + # default uses attachment behavior + datfile = self.write_file('dat.txt', 'hello') + response = view.file_response(datfile) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_disposition, 'attachment; filename="dat.txt"') + + # but can disable attachment behavior + datfile = self.write_file('dat.txt', 'hello') + response = view.file_response(datfile, attachment=False) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.content_disposition) + + # path not found + crapfile = '/does/not/exist' + response = view.file_response(crapfile) + self.assertEqual(response.status_code, 404) + def test_json_response(self): view = self.make_view() response = view.json_response({'foo': 'bar'}) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 6fdb55d..f979479 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -30,6 +30,8 @@ class TestMasterView(WebTestCase): model_key='uuid', deletable_bulk=True, has_autocomplete=True, + downloadable=True, + executable=True, configurable=True): mod.MasterView.defaults(self.pyramid_config) @@ -1310,6 +1312,59 @@ class TestMasterView(WebTestCase): self.assertEqual(normal, {'value': 'bogus', 'label': "Betty Boop"}) + def test_download(self): + model = self.app.model + self.app.save_setting(self.session, 'foo', 'bar') + self.session.commit() + + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting, + model_key='name', + Session=MagicMock(return_value=self.session)): + view = self.make_view() + self.request.matchdict = {'name': 'foo'} + + # 404 if no filename + response = view.download() + self.assertEqual(response.status_code, 404) + + # 404 if bad filename + self.request.GET = {'filename': 'doesnotexist'} + response = view.download() + self.assertEqual(response.status_code, 404) + + # 200 if good filename + foofile = self.write_file('foo.txt', 'foo') + with patch.object(view, 'download_path', return_value=foofile): + response = view.download() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_disposition, 'attachment; filename="foo.txt"') + + def test_execute(self): + self.pyramid_config.add_route('settings.view', '/settings/{name}') + model = self.app.model + self.app.save_setting(self.session, 'foo', 'bar') + self.session.commit() + + with patch.multiple(mod.MasterView, create=True, + model_class=model.Setting, + model_key='name', + Session=MagicMock(return_value=self.session)): + view = self.make_view() + self.request.matchdict = {'name': 'foo'} + + # basic usage, redirects to view obj url + response = view.execute() + self.assertEqual(response.status_code, 302) + self.assertEqual(self.request.session.pop_flash(), ["Setting was executed."]) + + # execution error + with patch.object(view, 'execute_instance', side_effect=RuntimeError): + response = view.execute() + self.assertEqual(response.status_code, 302) + self.assertEqual(self.request.session.pop_flash(), []) + self.assertEqual(self.request.session.pop_flash('error'), ["RuntimeError"]) + def test_configure(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py index 4641029..5d6db33 100644 --- a/tests/views/test_upgrades.py +++ b/tests/views/test_upgrades.py @@ -1,9 +1,12 @@ # -*- coding: utf-8; -*- import datetime +import os +import sys from unittest.mock import patch, MagicMock from wuttaweb.views import upgrades as mod +from wuttjamaican.exc import ConfigurationError from tests.util import WebTestCase @@ -42,6 +45,7 @@ class TestUpgradeView(WebTestCase): self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') def test_configure_form(self): + self.pyramid_config.add_route('upgrades.download', '/upgrades/{uuid}/download') model = self.app.model enum = self.app.enum user = model.User(username='barney') @@ -66,7 +70,7 @@ class TestUpgradeView(WebTestCase): view.configure_form(form) self.assertNotIn('created', form) - # test executed field when viewing + # test executed, stdout/stderr when viewing with patch.object(view, 'viewing', new=True): # executed is *not* shown by default @@ -74,13 +78,18 @@ class TestUpgradeView(WebTestCase): self.assertIn('executed', form) view.configure_form(form) self.assertNotIn('executed', form) + self.assertNotIn('stdout_file', form) + self.assertNotIn('stderr_file', form) # but it *is* shown if upgrade is executed upgrade.executed = datetime.datetime.now() + upgrade.status = enum.UpgradeStatus.SUCCESS form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) self.assertIn('executed', form) view.configure_form(form) self.assertIn('executed', form) + self.assertIn('stdout_file', form) + self.assertIn('stderr_file', form) def test_objectify(self): model = self.app.model @@ -101,3 +110,167 @@ class TestUpgradeView(WebTestCase): self.assertEqual(upgrade.description, "new one") self.assertIs(upgrade.created_by, user) self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) + + def test_download_path(self): + model = self.app.model + enum = self.app.enum + + appdir = self.mkdir('app') + self.config.setdefault('wutta.appdir', appdir) + self.assertEqual(self.app.get_appdir(), appdir) + + user = model.User(username='barney') + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + view = self.make_view() + uuid = upgrade.uuid + + # no filename + path = view.download_path(upgrade, None) + self.assertIsNone(path) + + # with filename + path = view.download_path(upgrade, 'foo.txt') + self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', + uuid[:2], uuid[2:], 'foo.txt')) + + def test_get_upgrade_filepath(self): + model = self.app.model + enum = self.app.enum + + appdir = self.mkdir('app') + self.config.setdefault('wutta.appdir', appdir) + self.assertEqual(self.app.get_appdir(), appdir) + + user = model.User(username='barney') + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + view = self.make_view() + uuid = upgrade.uuid + + # no filename + path = view.get_upgrade_filepath(upgrade) + self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', + uuid[:2], uuid[2:])) + + # with filename + path = view.get_upgrade_filepath(upgrade, 'foo.txt') + self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', + uuid[:2], uuid[2:], 'foo.txt')) + + def test_delete_instance(self): + model = self.app.model + enum = self.app.enum + + appdir = self.mkdir('app') + self.config.setdefault('wutta.appdir', appdir) + self.assertEqual(self.app.get_appdir(), appdir) + + user = model.User(username='barney') + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + view = self.make_view() + + # mock stdout/stderr files + upgrade_dir = view.get_upgrade_filepath(upgrade) + stdout = view.get_upgrade_filepath(upgrade, 'stdout.log') + with open(stdout, 'w') as f: + f.write('stdout') + stderr = view.get_upgrade_filepath(upgrade, 'stderr.log') + with open(stderr, 'w') as f: + f.write('stderr') + + # both upgrade and files are deleted + self.assertTrue(os.path.exists(upgrade_dir)) + self.assertTrue(os.path.exists(stdout)) + self.assertTrue(os.path.exists(stderr)) + self.assertEqual(self.session.query(model.Upgrade).count(), 1) + with patch.object(view, 'Session', return_value=self.session): + view.delete_instance(upgrade) + self.assertFalse(os.path.exists(upgrade_dir)) + self.assertFalse(os.path.exists(stdout)) + self.assertFalse(os.path.exists(stderr)) + self.assertEqual(self.session.query(model.Upgrade).count(), 0) + + def test_execute_instance(self): + model = self.app.model + enum = self.app.enum + + appdir = self.mkdir('app') + self.config.setdefault('wutta.appdir', appdir) + self.assertEqual(self.app.get_appdir(), appdir) + + user = model.User(username='barney') + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + view = self.make_view() + self.request.user = user + python = sys.executable + + # script not yet confiugred + self.assertRaises(ConfigurationError, view.execute_instance, upgrade) + + # script w/ success + goodpy = self.write_file('good.py', """ +import sys +sys.stdout.write('hello from good.py') +sys.exit(0) +""") + self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {goodpy}') + self.assertIsNone(upgrade.executed) + self.assertIsNone(upgrade.executed_by) + self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.config, 'usedb', new=True): + view.execute_instance(upgrade) + self.assertIsNotNone(upgrade.executed) + self.assertIs(upgrade.executed_by, user) + self.assertEqual(upgrade.status, enum.UpgradeStatus.SUCCESS) + with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f: + self.assertEqual(f.read(), 'hello from good.py') + with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f: + self.assertEqual(f.read(), '') + + # need a new record for next test + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + # script w/ failure + badpy = self.write_file('bad.py', """ +import sys +sys.stderr.write('hello from bad.py') +sys.exit(42) +""") + self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {badpy}') + self.assertIsNone(upgrade.executed) + self.assertIsNone(upgrade.executed_by) + self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.config, 'usedb', new=True): + view.execute_instance(upgrade) + self.assertIsNotNone(upgrade.executed) + self.assertIs(upgrade.executed_by, user) + self.assertEqual(upgrade.status, enum.UpgradeStatus.FAILURE) + with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f: + self.assertEqual(f.read(), '') + with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f: + self.assertEqual(f.read(), 'hello from bad.py') + + def test_configure_get_simple_settings(self): + # sanity/coverage check + view = self.make_view() + simple = view.configure_get_simple_settings() From 8669ca2283e6d21f5a799ab2c0e7df090cd12f14 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Aug 2024 15:52:29 -0500 Subject: [PATCH 5/9] feat: add "progress" page for executing upgrades show scrolling stdout from subprocess nb. this does *not* show stderr, although that is captured --- src/wuttaweb/templates/progress.mako | 7 +- src/wuttaweb/templates/upgrade.mako | 64 +++++++++++++ src/wuttaweb/views/master.py | 129 ++++++++++++++++++++++----- src/wuttaweb/views/upgrades.py | 80 +++++++++++++++-- tests/views/test_master.py | 59 +++++++++--- tests/views/test_upgrades.py | 94 ++++++++++++++++++- 6 files changed, 392 insertions(+), 41 deletions(-) create mode 100644 src/wuttaweb/templates/upgrade.mako diff --git a/src/wuttaweb/templates/progress.mako b/src/wuttaweb/templates/progress.mako index 35223a1..ddf5a74 100644 --- a/src/wuttaweb/templates/progress.mako +++ b/src/wuttaweb/templates/progress.mako @@ -55,7 +55,7 @@ <%def name="modify_vue_vars()"> <script> - WholePageData.progressURL = '${url('progress', key=progress.key, _query={'sessiontype': progress.session.type})}' + WholePageData.progressURL = '${url('progress', key=progress.key)}' WholePageData.progressMessage = "${(initial_msg or "Working").replace('"', '\\"')} (please wait)" WholePageData.progressMax = null WholePageData.progressMaxDisplay = null @@ -107,6 +107,9 @@ } } + // custom logic if applicable + this.updateProgressCustom(response) + if (this.stillInProgress) { // fetch progress data again, in one second from now @@ -118,5 +121,7 @@ }) } + WholePage.methods.updateProgressCustom = function(response) {} + </script> </%def> diff --git a/src/wuttaweb/templates/upgrade.mako b/src/wuttaweb/templates/upgrade.mako new file mode 100644 index 0000000..72a4424 --- /dev/null +++ b/src/wuttaweb/templates/upgrade.mako @@ -0,0 +1,64 @@ +## -*- coding: utf-8; -*- +<%inherit file="/progress.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + + .upgrade-textout { + border: 1px solid Black; + line-height: 1.2; + margin-top: 1rem; + overflow: auto; + padding: 1rem; + } + + </style> +</%def> + +<%def name="after_progress()"> + <div ref="textout" + class="upgrade-textout is-family-monospace is-size-7"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' + WholePageData.progressOutput = [] + WholePageData.progressOutputCounter = 0 + + WholePageData.mountedHooks.push(function() { + + // grow the textout area to fill most of screen + const textout = this.$refs.textout + const height = window.innerHeight - textout.offsetTop - 100 + textout.style.height = height + 'px' + }) + + WholePage.methods.updateProgressCustom = function(response) { + if (response.data.stdout) { + + // add lines to textout area + this.progressOutput.push({ + key: ++this.progressOutputCounter, + text: response.data.stdout}) + + // scroll down to end of textout area + this.$nextTick(() => { + this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) + }) + } + } + + </script> +</%def> diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index fe333b1..c5ff5d7 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -76,7 +76,7 @@ class MasterView(View): Optional reference to a data model class. While not strictly required, most views will set this to a SQLAlchemy mapped class, - e.g. :class:`wuttjamaican:wuttjamaican.db.model.auth.User`. + e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. Code should not access this directly but instead call :meth:`get_model_class()`. @@ -365,6 +365,7 @@ class MasterView(View): has_autocomplete = False downloadable = False executable = False + execute_progress_template = None configurable = False # current action @@ -725,7 +726,7 @@ class MasterView(View): len(records), model_title_plural, exc_info=True) if progress: - progress.handle_error(error, "Bulk deletion failed") + progress.handle_error(error) else: session.commit() @@ -953,21 +954,26 @@ class MasterView(View): * :meth:`execute_instance()` """ + route_prefix = self.get_route_prefix() model_title = self.get_model_title() obj = self.get_instance() - try: - self.execute_instance(obj) - except Exception as error: - log.exception("failed to execute %s: %s", model_title, obj) - error = str(error) or error.__class__.__name__ - self.request.session.flash(error, 'error') - else: - self.request.session.flash(f"{model_title} was executed.") + # make the progress tracker + progress = self.make_progress(f'{route_prefix}.execute', + success_msg=f"{model_title} was executed.", + success_url=self.get_action_url('view', obj)) - return self.redirect(self.get_action_url('view', obj)) + # start thread for execute; show progress page + key = self.request.matchdict + thread = threading.Thread(target=self.execute_thread, + args=(key, self.request.user.uuid), + kwargs={'progress': progress}) + thread.start() + return self.render_progress(progress, context={ + 'instance': obj, + }, template=self.execute_progress_template) - def execute_instance(self, obj): + def execute_instance(self, obj, user, progress=None): """ Perform the actual "execution" logic for a model record. Called by :meth:`execute()`. @@ -975,8 +981,43 @@ class MasterView(View): This method does nothing by default; subclass must override. :param obj: Reference to the model instance. + + :param user: Reference to the + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is doing the execute. + + :param progress: Optional progress indicator factory. """ + def execute_thread(self, key, user_uuid, progress=None): + """ """ + model = self.app.model + model_title = self.get_model_title() + + # nb. use new session, separate from web transaction + session = self.app.make_session() + + # fetch model instance and user for this session + obj = self.get_instance(session=session, matchdict=key) + user = session.get(model.User, user_uuid) + + try: + self.execute_instance(obj, user, progress=progress) + + except Exception as error: + session.rollback() + log.warning("%s failed to execute: %s", model_title, obj, exc_info=True) + if progress: + progress.handle_error(error) + + else: + session.commit() + if progress: + progress.handle_success() + + finally: + session.close() + ############################## # configure methods ############################## @@ -1847,23 +1888,69 @@ class MasterView(View): # for key in self.get_model_key(): # grid.set_link(key) - def get_instance(self, session=None): + def get_instance(self, session=None, matchdict=None): """ - This should return the "current" model instance based on the - request details (e.g. route kwargs). + This should return the appropriate model instance, based on + the ``matchdict`` of model keys. - If the instance cannot be found, this should raise a HTTP 404 - exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`. + Normally this is called with no arguments, in which case the + :attr:`pyramid:pyramid.request.Request.matchdict` is used, and + will return the "current" model instance based on the request + (route/params). - There is no "sane" default logic here; subclass *must* - override or else a ``NotImplementedError`` is raised. + If a ``matchdict`` is provided then that is used instead, to + obtain the model keys. In the simple/common example of a + "native" model in WuttaWeb, this would look like:: + + keys = {'uuid': '38905440630d11ef9228743af49773a4'} + obj = self.get_instance(matchdict=keys) + + Although some models may have different, possibly composite + key names to use instead. The specific keys this logic is + expecting are the same as returned by :meth:`get_model_key()`. + + If this method is unable to locate the instance, it should + raise a 404 error, + i.e. :meth:`~wuttaweb.views.base.View.notfound()`. + + Default implementation of this method should work okay for + views which define a :attr:`model_class`. For other views + however it will raise ``NotImplementedError``, so subclass + may need to define. + + .. warning:: + + If you are defining this method for a subclass, please note + this point regarding the 404 "not found" logic. + + It is *not* enough to simply *return* this 404 response, + you must explicitly *raise* the error. For instance:: + + def get_instance(self, **kwargs): + + # ..try to locate instance.. + obj = self.locate_instance_somehow() + + if not obj: + + # NB. THIS MAY NOT WORK AS EXPECTED + #return self.notfound() + + # nb. should always do this in get_instance() + raise self.notfound() + + This lets calling code not have to worry about whether or + not this method might return ``None``. It can safely + assume it will get back a model instance, or else a 404 + will kick in and control flow goes elsewhere. """ model_class = self.get_model_class() if model_class: session = session or self.Session() + matchdict = matchdict or self.request.matchdict def filtr(query, model_key): - key = self.request.matchdict[model_key] + key = matchdict[model_key] query = query.filter(getattr(self.model_class, model_key) == key) return query @@ -2126,7 +2213,7 @@ class MasterView(View): Returns the model class for the view (if defined). A model class will *usually* be a SQLAlchemy mapped class, - e.g. :class:`wuttjamaican:wuttjamaican.db.model.base.Person`. + e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. There is no default value here, but a subclass may override by assigning :attr:`model_class`. diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py index ee4a2bd..03570f3 100644 --- a/src/wuttaweb/views/upgrades.py +++ b/src/wuttaweb/views/upgrades.py @@ -36,6 +36,7 @@ from wuttjamaican.db.model import Upgrade from wuttaweb.views import MasterView from wuttaweb.forms import widgets from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload +from wuttaweb.progress import get_progress_session log = logging.getLogger(__name__) @@ -57,6 +58,7 @@ class UpgradeView(MasterView): """ model_class = Upgrade executable = True + execute_progress_template = '/upgrade.mako' downloadable = True configurable = True @@ -222,7 +224,7 @@ class UpgradeView(MasterView): path = os.path.join(path, filename) return path - def execute_instance(self, upgrade): + def execute_instance(self, upgrade, user, progress=None): """ This method runs the actual upgrade. @@ -236,28 +238,79 @@ class UpgradeView(MasterView): either ``SUCCESS`` or ``FAILURE``. """ enum = self.app.enum - script = self.config.require(f'{self.app.appname}.upgrades.command', - session=self.Session()) + + # locate file paths + script = self.config.require(f'{self.app.appname}.upgrades.command') stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log') stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log') + # record the fact that execution has begun for this upgrade + # nb. this is done in separate session to ensure it sticks, + # but also update local object to reflect the change + with self.app.short_session(commit=True) as s: + alt = s.merge(upgrade) + alt.status = enum.UpgradeStatus.EXECUTING + upgrade.status = enum.UpgradeStatus.EXECUTING + # run the command log.debug("running upgrade command: %s", script) with open(stdout_path, 'wb') as stdout: with open(stderr_path, 'wb') as stderr: - upgrade.exit_code = subprocess.call(script, shell=True, + upgrade.exit_code = subprocess.call(script, shell=True, text=True, stdout=stdout, stderr=stderr) logger = log.warning if upgrade.exit_code != 0 else log.debug - logger("upgrade command had non-zero exit code: %s", upgrade.exit_code) + logger("upgrade command had exit code: %s", upgrade.exit_code) # declare it complete upgrade.executed = datetime.datetime.now() - upgrade.executed_by = self.request.user + upgrade.executed_by = user if upgrade.exit_code == 0: upgrade.status = enum.UpgradeStatus.SUCCESS else: upgrade.status = enum.UpgradeStatus.FAILURE + def execute_progress(self): + """ """ + route_prefix = self.get_route_prefix() + upgrade = self.get_instance() + session = get_progress_session(self.request, f'{route_prefix}.execute') + + # session has 'complete' flag set when operation is over + if session.get('complete'): + + # set a flash msg for user if one is defined. this is the + # time to do it since user is about to get redirected. + msg = session.get('success_msg') + if msg: + self.request.session.flash(msg) + + elif session.get('error'): # uh-oh + + # set an error flash msg for user. this is the time to do it + # since user is about to get redirected. + msg = session.get('error_msg', "An unspecified error occurred.") + self.request.session.flash(msg, 'error') + + # our return value will include all from progress session + data = dict(session) + + # add whatever might be new from upgrade process STDOUT + path = self.get_upgrade_filepath(upgrade, filename='stdout.log') + offset = session.get('stdout.offset', 0) + if os.path.exists(path): + size = os.path.getsize(path) - offset + if size > 0: + # with open(path, 'rb') as f: + with open(path) as f: + f.seek(offset) + chunk = f.read(size) + # data['stdout'] = chunk.decode('utf8').replace('\n', '<br />') + data['stdout'] = chunk.replace('\n', '<br />') + session['stdout.offset'] = offset + size + session.save() + + return data + def configure_get_simple_settings(self): """ """ @@ -283,6 +336,21 @@ class UpgradeView(MasterView): cls.model_class = app.model.Upgrade cls._defaults(config) + cls._upgrade_defaults(config) + + @classmethod + def _upgrade_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + + # execution progress + config.add_route(f'{route_prefix}.execute_progress', + f'{instance_url_prefix}/execute/progress') + config.add_view(cls, attr='execute_progress', + route_name=f'{route_prefix}.execute_progress', + permission=f'{permission_prefix}.execute', + renderer='json') def defaults(config, **kwargs): diff --git a/tests/views/test_master.py b/tests/views/test_master.py index f979479..7b75e0a 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1342,8 +1342,11 @@ class TestMasterView(WebTestCase): def test_execute(self): self.pyramid_config.add_route('settings.view', '/settings/{name}') + self.pyramid_config.add_route('progress', '/progress/{key}') model = self.app.model self.app.save_setting(self.session, 'foo', 'bar') + user = model.User(username='barney') + self.session.add(user) self.session.commit() with patch.multiple(mod.MasterView, create=True, @@ -1352,18 +1355,54 @@ class TestMasterView(WebTestCase): Session=MagicMock(return_value=self.session)): view = self.make_view() self.request.matchdict = {'name': 'foo'} + self.request.session.id = 'mockid' + self.request.user = user - # basic usage, redirects to view obj url - response = view.execute() - self.assertEqual(response.status_code, 302) - self.assertEqual(self.request.session.pop_flash(), ["Setting was executed."]) - - # execution error - with patch.object(view, 'execute_instance', side_effect=RuntimeError): + # basic usage; user is shown progress page + with patch.object(mod, 'threading') as threading: response = view.execute() - self.assertEqual(response.status_code, 302) - self.assertEqual(self.request.session.pop_flash(), []) - self.assertEqual(self.request.session.pop_flash('error'), ["RuntimeError"]) + threading.Thread.return_value.start.assert_called_once_with() + self.assertEqual(response.status_code, 200) + + def test_execute_thread(self): + model = self.app.model + enum = self.app.enum + user = model.User(username='barney') + self.session.add(user) + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + with patch.multiple(mod.MasterView, create=True, + model_class=model.Upgrade): + view = self.make_view() + + # basic execute, no progress + with patch.object(view, 'execute_instance') as execute_instance: + view.execute_thread({'uuid': upgrade.uuid}, user.uuid) + execute_instance.assert_called_once() + + # basic execute, with progress + with patch.object(view, 'execute_instance') as execute_instance: + progress = MagicMock() + view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress) + execute_instance.assert_called_once() + progress.handle_success.assert_called_once_with() + + # error, no progress + with patch.object(view, 'execute_instance') as execute_instance: + execute_instance.side_effect = RuntimeError + view.execute_thread({'uuid': upgrade.uuid}, user.uuid) + execute_instance.assert_called_once() + + # error, with progress + with patch.object(view, 'execute_instance') as execute_instance: + progress = MagicMock() + execute_instance.side_effect = RuntimeError + view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress) + execute_instance.assert_called_once() + progress.handle_error.assert_called_once() def test_configure(self): self.pyramid_config.include('wuttaweb.views.common') diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py index 5d6db33..6c89d5b 100644 --- a/tests/views/test_upgrades.py +++ b/tests/views/test_upgrades.py @@ -7,6 +7,7 @@ from unittest.mock import patch, MagicMock from wuttaweb.views import upgrades as mod from wuttjamaican.exc import ConfigurationError +from wuttaweb.progress import get_progress_session from tests.util import WebTestCase @@ -220,7 +221,7 @@ class TestUpgradeView(WebTestCase): python = sys.executable # script not yet confiugred - self.assertRaises(ConfigurationError, view.execute_instance, upgrade) + self.assertRaises(ConfigurationError, view.execute_instance, upgrade, user) # script w/ success goodpy = self.write_file('good.py', """ @@ -234,7 +235,7 @@ sys.exit(0) self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) with patch.object(view, 'Session', return_value=self.session): with patch.object(self.config, 'usedb', new=True): - view.execute_instance(upgrade) + view.execute_instance(upgrade, user) self.assertIsNotNone(upgrade.executed) self.assertIs(upgrade.executed_by, user) self.assertEqual(upgrade.status, enum.UpgradeStatus.SUCCESS) @@ -261,7 +262,7 @@ sys.exit(42) self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) with patch.object(view, 'Session', return_value=self.session): with patch.object(self.config, 'usedb', new=True): - view.execute_instance(upgrade) + view.execute_instance(upgrade, user) self.assertIsNotNone(upgrade.executed) self.assertIs(upgrade.executed_by, user) self.assertEqual(upgrade.status, enum.UpgradeStatus.FAILURE) @@ -270,6 +271,93 @@ sys.exit(42) with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f: self.assertEqual(f.read(), 'hello from bad.py') + def test_execute_progress(self): + model = self.app.model + enum = self.app.enum + view = self.make_view() + + user = model.User(username='barney') + self.session.add(user) + upgrade = model.Upgrade(description='test', created_by=user, + status=enum.UpgradeStatus.PENDING) + self.session.add(upgrade) + self.session.commit() + + stdout = self.write_file('stdout.log', 'hello 001\n') + + self.request.matchdict = {'uuid': upgrade.uuid} + with patch.multiple(mod.UpgradeView, + Session=MagicMock(return_value=self.session), + get_upgrade_filepath=MagicMock(return_value=stdout)): + + # nb. this is used to identify progress tracker + self.request.session.id = 'mockid#1' + + # first call should get the full contents + context = view.execute_progress() + self.assertFalse(context.get('complete')) + self.assertFalse(context.get('error')) + # nb. newline is converted to <br> + self.assertEqual(context['stdout'], 'hello 001<br />') + + # next call should get any new contents + with open(stdout, 'a') as f: + f.write('hello 002\n') + context = view.execute_progress() + self.assertFalse(context.get('complete')) + self.assertFalse(context.get('error')) + self.assertEqual(context['stdout'], 'hello 002<br />') + + # nb. switch to a different progress tracker + self.request.session.id = 'mockid#2' + + # first call should get the full contents + context = view.execute_progress() + self.assertFalse(context.get('complete')) + self.assertFalse(context.get('error')) + self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />') + + # mark progress complete + session = get_progress_session(self.request, 'upgrades.execute') + session.load() + session['complete'] = True + session['success_msg'] = 'yay!' + session.save() + + # next call should reflect that + self.assertEqual(self.request.session.pop_flash(), []) + context = view.execute_progress() + self.assertTrue(context.get('complete')) + self.assertFalse(context.get('error')) + # nb. this is missing b/c we already got all contents + self.assertNotIn('stdout', context) + self.assertEqual(self.request.session.pop_flash(), ['yay!']) + + # nb. switch to a different progress tracker + self.request.session.id = 'mockid#3' + + # first call should get the full contents + context = view.execute_progress() + self.assertFalse(context.get('complete')) + self.assertFalse(context.get('error')) + self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />') + + # mark progress error + session = get_progress_session(self.request, 'upgrades.execute') + session.load() + session['error'] = True + session['error_msg'] = 'omg!' + session.save() + + # next call should reflect that + self.assertEqual(self.request.session.pop_flash('error'), []) + context = view.execute_progress() + self.assertFalse(context.get('complete')) + self.assertTrue(context.get('error')) + # nb. this is missing b/c we already got all contents + self.assertNotIn('stdout', context) + self.assertEqual(self.request.session.pop_flash('error'), ['omg!']) + def test_configure_get_simple_settings(self): # sanity/coverage check view = self.make_view() From 4934ed1d931197b0e80edd174bbff0a2e5f731f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Aug 2024 20:25:14 -0500 Subject: [PATCH 6/9] feat: add basic user feedback email mechanism this definitely needs some more work. using pyramid_mailer for testing although not ready to declare that dependency. for now this is "broken" without it being installed. --- src/wuttaweb/templates/base.mako | 165 +++++++++++++++++- .../templates/temporary/feedback.html.mako | 40 +++++ .../templates/temporary/feedback.txt.mako | 23 +++ src/wuttaweb/templates/wutta-components.mako | 77 ++++++++ src/wuttaweb/views/common.py | 92 ++++++++++ tests/views/test_common.py | 66 +++++++ 6 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/wuttaweb/templates/temporary/feedback.html.mako create mode 100644 src/wuttaweb/templates/temporary/feedback.txt.mako diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 3b3f115..dbad2e2 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -420,7 +420,153 @@ <%def name="render_theme_picker()"></%def> -<%def name="render_feedback_button()"></%def> +<%def name="render_feedback_button()"> + % if request.has_perm('common.feedback'): + <wutta-feedback-form action="${url('feedback')}" /> + % endif +</%def> + +<%def name="render_vue_template_feedback()"> + <script type="text/x-template" id="wutta-feedback-template"> + <div> + + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + + <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"> + Feedback regarding this website may be submitted below. + </p> + + <b-field label="User Name" + :type="userName && userName.trim() ? null : 'is-danger'"> + <b-input v-model.trim="userName" + % if request.user: + disabled + % endif + /> + </b-field> + + <b-field label="Referring URL"> + <b-input v-model="referrer" + disabled="true" /> + </b-field> + + <b-field label="Message" + :type="message && message.trim() ? null : 'is-danger'"> + <b-input type="textarea" + v-model.trim="message" + ref="textarea" /> + </b-field> + </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="submitDisabled"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> + </div> + </b-modal> + + </div> + </script> +</%def> + +<%def name="render_vue_script_feedback()"> + <script> + + const WuttaFeedbackForm = { + template: '#wutta-feedback-template', + mixins: [WuttaRequestMixin], + props: { + action: String, + }, + computed: { + + submitDisabled() { + if (this.sendingFeedback) { + return true + } + if (!this.userName || !this.userName.trim()) { + return true + } + if (!this.message || !this.message.trim()) { + return true + } + return false + }, + }, + methods: { + + showFeedback() { + // nb. update referrer to include anchor hash if any + this.referrer = location.href + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, + + sendFeedback() { + this.sendingFeedback = true + + const params = { + referrer: this.referrer, + user_uuid: this.userUUID, + user_name: this.userName, + message: this.message.trim(), + } + + this.wuttaPOST(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 WuttaFeedbackFormData = { + referrer: null, + userUUID: ${json.dumps(request.user.uuid if request.user else None)|n}, + userName: ${json.dumps(str(request.user) if request.user else None)|n}, + showDialog: false, + sendingFeedback: false, + message: '', + } + + </script> +</%def> <%def name="render_vue_script_whole_page()"> <script> @@ -578,18 +724,33 @@ ############################## <%def name="render_vue_templates()"> + + ## nb. must make wutta components first; they are stable so + ## intermediate pages do not need to modify them. and some pages + ## may need the request mixin to be defined. + ${make_wutta_components()} + ${self.render_vue_template_whole_page()} ${self.render_vue_script_whole_page()} + % if request.has_perm('common.feedback'): + ${self.render_vue_template_feedback()} + ${self.render_vue_script_feedback()} + % endif </%def> <%def name="modify_vue_vars()"></%def> <%def name="make_vue_components()"> - ${make_wutta_components()} <script> WholePage.data = function() { return WholePageData } Vue.component('whole-page', WholePage) </script> + % if request.has_perm('common.feedback'): + <script> + WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData } + Vue.component('wutta-feedback-form', WuttaFeedbackForm) + </script> + % endif </%def> <%def name="make_vue_app()"> diff --git a/src/wuttaweb/templates/temporary/feedback.html.mako b/src/wuttaweb/templates/temporary/feedback.html.mako new file mode 100644 index 0000000..c483f1a --- /dev/null +++ b/src/wuttaweb/templates/temporary/feedback.html.mako @@ -0,0 +1,40 @@ +## -*- coding: utf-8 -*- +<html> + <head> + <style type="text/css"> + label { + display: block; + font-weight: bold; + margin-top: 1em; + } + p { + margin: 1em 0 1em 1.5em; + } + p.msg { + white-space: pre-wrap; + } + </style> + </head> + <body> + <h1>User feedback from website</h1> + + <label>User Name</label> + <p> + % if user: + <a href="${user_url}">${user}</a> + % else: + ${user_name} + % endif + </p> + + <label>Referring URL</label> + <p><a href="${referrer}">${referrer}</a></p> + + <label>Client IP</label> + <p>${client_ip}</p> + + <label>Message</label> + <p class="msg">${message}</p> + + </body> +</html> diff --git a/src/wuttaweb/templates/temporary/feedback.txt.mako b/src/wuttaweb/templates/temporary/feedback.txt.mako new file mode 100644 index 0000000..b0d396a --- /dev/null +++ b/src/wuttaweb/templates/temporary/feedback.txt.mako @@ -0,0 +1,23 @@ +## -*- coding: utf-8; -*- + +# User feedback from website + +**User Name** + +% if user: + ${user} +% else: + ${user_name} +% endif + +**Referring URL** + +${referrer} + +**Client IP** + +${client_ip} + +**Message** + +${message} diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index 888944e..b52992e 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -1,10 +1,87 @@ <%def name="make_wutta_components()"> + ${self.make_wutta_request_mixin()} ${self.make_wutta_button_component()} ${self.make_wutta_filter_component()} ${self.make_wutta_filter_value_component()} </%def> +<%def name="make_wutta_request_mixin()"> + <script> + + const WuttaRequestMixin = { + methods: { + + wuttaGET(url, params, success, failure) { + + this.$http.get(url, {params: params}).then(response => { + + if (response.data.error) { + this.$buefy.toast.open({ + message: `Request failed: ${'$'}{response.data.error}`, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + + } else { + success(response) + } + + }, response => { + this.$buefy.toast.open({ + message: "Request failed: (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + }) + + }, + + wuttaPOST(action, params, success, failure) { + + const csrftoken = ${json.dumps(h.get_csrf_token(request))|n} + const headers = {'X-CSRF-TOKEN': csrftoken} + + this.$http.post(action, params, {headers: headers}).then(response => { + + if (response.data.error) { + this.$buefy.toast.open({ + message: "Submit failed: " + (response.data.error || + "(unknown error)"), + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + + } else { + success(response) + } + + }, response => { + this.$buefy.toast.open({ + message: "Submit failed! (unknown server error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + if (failure) { + failure(response) + } + }) + }, + }, + } + + </script> +</%def> + <%def name="make_wutta_button_component()"> <script type="text/x-template" id="wutta-button-template"> <b-button :type="type" diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index 279c61a..a68e298 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -24,13 +24,19 @@ Common Views """ +import logging + import colander +from pyramid.renderers import render from wuttaweb.views import View from wuttaweb.forms import widgets from wuttaweb.db import Session +log = logging.getLogger(__name__) + + class CommonView(View): """ Common views shared by all apps. @@ -78,6 +84,80 @@ class CommonView(View): """ return {'index_title': self.app.get_title()} + def feedback(self): + """ """ + model = self.app.model + session = Session() + + # validate form + schema = self.feedback_make_schema() + form = self.make_form(schema=schema) + if not form.validate(): + # TODO: native Form class should better expose error(s) + dform = form.get_deform() + return {'error': str(dform.error)} + + # build email template context + context = dict(form.validated) + if context['user_uuid']: + context['user'] = session.get(model.User, context['user_uuid']) + context['user_url'] = self.request.route_url('users.view', uuid=context['user_uuid']) + context['client_ip'] = self.request.client_addr + + # send email + try: + self.feedback_send(context) + except Exception as error: + log.warning("failed to send feedback email", exc_info=True) + return {'error': str(error) or error.__class__.__name__} + + return {'ok': True} + + def feedback_make_schema(self): + """ """ + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='referrer')) + + schema.add(colander.SchemaNode(colander.String(), + name='user_uuid', + missing=None)) + + schema.add(colander.SchemaNode(colander.String(), + name='user_name')) + + schema.add(colander.SchemaNode(colander.String(), + name='message')) + + return schema + + def feedback_send(self, context): # pragma: no cover + """ """ + + # TODO: this is definitely a stopgap bit of logic, until we + # have a more robust way to handle email via wuttjamaican etc. + + from pyramid_mailer.mailer import Mailer + from pyramid_mailer.message import Message + + From = self.config.require(f'{self.config.appname}.email.default.sender') + To = self.config.require(f'{self.config.appname}.email.feedback.to') + Subject = self.config.get(f'{self.config.appname}.email.feedback.subject', + default="User Feedback") + + text_body = render('/temporary/feedback.txt.mako', context, request=self.request) + html_body = render('/temporary/feedback.html.mako', context, request=self.request) + + msg = Message(subject=Subject, + sender=From, + recipients=[To], + body=text_body, + html=html_body) + + mailer = Mailer() + mailer.send_immediately(msg) + def setup(self, session=None): """ View for first-time app setup, to create admin user. @@ -203,6 +283,8 @@ class CommonView(View): @classmethod def _defaults(cls, config): + config.add_wutta_permission_group('common', "(common)", overwrite=False) + # home page config.add_route('home', '/') config.add_view(cls, attr='home', @@ -219,6 +301,16 @@ class CommonView(View): append_slash=True, renderer='/notfound.mako') + # feedback + config.add_route('feedback', '/feedback', + request_method='POST') + config.add_view(cls, attr='feedback', + route_name='feedback', + permission='common.feedback', + renderer='json') + config.add_wutta_permission('common', 'common.feedback', + "Send user feedback about the app") + # setup config.add_route('setup', '/setup') config.add_view(cls, attr='setup', diff --git a/tests/views/test_common.py b/tests/views/test_common.py index bf240b5..9fe0074 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -1,5 +1,9 @@ # -*- coding: utf-8; -*- +from unittest.mock import patch + +import colander + from wuttaweb.views import common as mod from tests.util import WebTestCase @@ -51,6 +55,68 @@ class TestCommonView(WebTestCase): context = view.home(session=self.session) self.assertEqual(context['index_title'], self.app.get_title()) + def test_feedback_make_schema(self): + view = self.make_view() + schema = view.feedback_make_schema() + self.assertIsInstance(schema, colander.Schema) + self.assertIn('message', schema) + + def test_feedback(self): + self.pyramid_config.add_route('users.view', '/users/{uuid}') + model = self.app.model + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + + view = self.make_view() + with patch.object(view, 'feedback_send') as feedback_send: + + # basic send, no user + self.request.client_addr = '127.0.0.1' + self.request.method = 'POST' + self.request.POST = { + 'referrer': '/foo', + 'user_name': "Barney Rubble", + 'message': "hello world", + } + context = view.feedback() + self.assertEqual(context, {'ok': True}) + feedback_send.assert_called_once() + + # reset + feedback_send.reset_mock() + + # basic send, with user + self.request.user = user + self.request.POST['user_uuid'] = user.uuid + with patch.object(mod, 'Session', return_value=self.session): + context = view.feedback() + self.assertEqual(context, {'ok': True}) + feedback_send.assert_called_once() + + # reset + self.request.user = None + feedback_send.reset_mock() + + # invalid form data + self.request.POST = {'message': 'hello world'} + context = view.feedback() + self.assertEqual(list(context), ['error']) + self.assertIn('Required', context['error']) + feedback_send.assert_not_called() + + # error on send + self.request.POST = { + 'referrer': '/foo', + 'user_name': "Barney Rubble", + 'message': "hello world", + } + feedback_send.side_effect = RuntimeError + context = view.feedback() + feedback_send.assert_called_once() + self.assertEqual(list(context), ['error']) + self.assertIn('RuntimeError', context['error']) + def test_setup(self): self.pyramid_config.add_route('home', '/') self.pyramid_config.add_route('login', '/login') From a377061da05fcc755990f4970eb51e0ec52682a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 25 Aug 2024 20:27:43 -0500 Subject: [PATCH 7/9] fix: tweak max image size for full logo on home, login pages as it happens these are the same dimensions for the default logo image. they seem standard but i don't know much about that.. --- src/wuttaweb/templates/auth/login.mako | 4 ++-- src/wuttaweb/templates/home.mako | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/templates/auth/login.mako b/src/wuttaweb/templates/auth/login.mako index 7c1d846..0235885 100644 --- a/src/wuttaweb/templates/auth/login.mako +++ b/src/wuttaweb/templates/auth/login.mako @@ -21,8 +21,8 @@ justify-content: center; } .wutta-logo img { - max-height: 350px; - max-width: 800px; + max-height: 480px; + max-width: 640px; } </style> </%def> diff --git a/src/wuttaweb/templates/home.mako b/src/wuttaweb/templates/home.mako index e387227..46d80cd 100644 --- a/src/wuttaweb/templates/home.mako +++ b/src/wuttaweb/templates/home.mako @@ -21,8 +21,8 @@ justify-content: center; } .wutta-logo img { - max-height: 350px; - max-width: 800px; + max-height: 480px; + max-width: 640px; } </style> </%def> From a010071985ecad20394c0b96c91f3aa9954cb986 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 13:21:05 -0500 Subject: [PATCH 8/9] feat: use native wuttjamaican app to send feedback email --- pyproject.toml | 2 +- src/wuttaweb/app.py | 5 +- .../templates}/feedback.html.mako | 0 .../templates}/feedback.txt.mako | 4 +- src/wuttaweb/templates/appinfo/configure.mako | 61 +++++++++++++++++++ src/wuttaweb/templates/appinfo/index.mako | 5 +- src/wuttaweb/templates/configure.mako | 6 +- src/wuttaweb/views/common.py | 26 +------- src/wuttaweb/views/settings.py | 17 ++++-- tests/views/test_common.py | 10 +++ 10 files changed, 101 insertions(+), 35 deletions(-) rename src/wuttaweb/{templates/temporary => email/templates}/feedback.html.mako (100%) rename src/wuttaweb/{templates/temporary => email/templates}/feedback.txt.mako (78%) diff --git a/pyproject.toml b/pyproject.toml index a671cb5..499bd04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.12.1", + "WuttJamaican[db,email]>=0.12.1", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 88318b4..c263b60 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -37,9 +37,10 @@ from wuttaweb.auth import WuttaSecurityPolicy class WebAppProvider(AppProvider): """ - The :term:`app provider` for WuttaWeb. This adds some methods - specific to web apps. + The :term:`app provider` for WuttaWeb. This adds some methods to + the :term:`app handler`, which are specific to web apps. """ + email_templates = 'wuttaweb:email/templates' def get_web_handler(self, **kwargs): """ diff --git a/src/wuttaweb/templates/temporary/feedback.html.mako b/src/wuttaweb/email/templates/feedback.html.mako similarity index 100% rename from src/wuttaweb/templates/temporary/feedback.html.mako rename to src/wuttaweb/email/templates/feedback.html.mako diff --git a/src/wuttaweb/templates/temporary/feedback.txt.mako b/src/wuttaweb/email/templates/feedback.txt.mako similarity index 78% rename from src/wuttaweb/templates/temporary/feedback.txt.mako rename to src/wuttaweb/email/templates/feedback.txt.mako index b0d396a..a73a55e 100644 --- a/src/wuttaweb/templates/temporary/feedback.txt.mako +++ b/src/wuttaweb/email/templates/feedback.txt.mako @@ -5,9 +5,9 @@ **User Name** % if user: - ${user} +${user} % else: - ${user_name} +${user_name} % endif **Referring URL** diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index d2de2cf..03f1551 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -73,6 +73,54 @@ </div> + <h3 class="block is-size-3">Email</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field> + <b-checkbox name="${config.appname}.mail.send_emails" + v-model="simpleSettings['${config.appname}.mail.send_emails']" + native-value="true" + @input="settingsNeedSaved = true"> + Enable email sending + </b-checkbox> + </b-field> + + <div v-show="simpleSettings['${config.appname}.mail.send_emails']"> + + <b-field label="Default Sender"> + <b-input name="${app.appname}.email.default.sender" + v-model="simpleSettings['${app.appname}.email.default.sender']" + @input="settingsNeedSaved = true" /> + </b-field> + + <b-field label="Default Recipient(s)"> + <b-input name="${app.appname}.email.default.to" + v-model="simpleSettings['${app.appname}.email.default.to']" + @input="settingsNeedSaved = true" /> + </b-field> + + <b-field label="Default Subject (optional)"> + <b-input name="${app.appname}.email.default.subject" + v-model="simpleSettings['${app.appname}.email.default.subject']" + @input="settingsNeedSaved = true" /> + </b-field> + + <b-field label="Feedback Recipient(s) (optional)"> + <b-input name="${app.appname}.email.feedback.to" + v-model="simpleSettings['${app.appname}.email.feedback.to']" + @input="settingsNeedSaved = true" /> + </b-field> + + <b-field label="Feedback Subject (optional)"> + <b-input name="${app.appname}.email.feedback.subject" + v-model="simpleSettings['${app.appname}.email.feedback.subject']" + @input="settingsNeedSaved = true" /> + </b-field> + + </div> + + </div> + <h3 class="block is-size-3">Web Libraries</h3> <div class="block" style="padding-left: 2rem;"> @@ -219,6 +267,19 @@ this.editWebLibraryShowDialog = false } + ThisPage.methods.validateEmailSettings = function() { + if (this.simpleSettings['${config.appname}.mail.send_emails']) { + if (!this.simpleSettings['${config.appname}.email.default.sender']) { + return "Default Sender is required to send email." + } + if (!this.simpleSettings['${config.appname}.email.default.to']) { + return "Default Recipient(s) are required to send email." + } + } + } + + ThisPageData.validators.push(ThisPage.methods.validateEmailSettings) + </script> </%def> diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 279a41e..383157f 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -20,7 +20,10 @@ <span>${app.get_node_title()}</span> </b-field> <b-field horizontal label="Production Mode"> - <span>${config.production()}</span> + <span>${"Yes" if config.production() else "No"}</span> + </b-field> + <b-field horizontal label="Email Enabled"> + <span>${"Yes" if app.get_email_handler().sending_is_enabled() else "No"}</span> </b-field> </div> </div> diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako index f0363e0..20878a3 100644 --- a/src/wuttaweb/templates/configure.mako +++ b/src/wuttaweb/templates/configure.mako @@ -167,7 +167,11 @@ for (let validator of this.validators) { let msg = validator.call(this) if (msg) { - alert(msg) + this.$buefy.toast.open({ + message: msg, + type: 'is-warning', + duration: 4000, // 4 seconds + }) return } } diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index a68e298..309ecc3 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -132,31 +132,9 @@ class CommonView(View): return schema - def feedback_send(self, context): # pragma: no cover + def feedback_send(self, context): """ """ - - # TODO: this is definitely a stopgap bit of logic, until we - # have a more robust way to handle email via wuttjamaican etc. - - from pyramid_mailer.mailer import Mailer - from pyramid_mailer.message import Message - - From = self.config.require(f'{self.config.appname}.email.default.sender') - To = self.config.require(f'{self.config.appname}.email.feedback.to') - Subject = self.config.get(f'{self.config.appname}.email.feedback.subject', - default="User Feedback") - - text_body = render('/temporary/feedback.txt.mako', context, request=self.request) - html_body = render('/temporary/feedback.html.mako', context, request=self.request) - - msg = Message(subject=Subject, - sender=From, - recipients=[To], - body=text_body, - html=html_body) - - mailer = Mailer() - mailer.send_immediately(msg) + self.app.send_email('feedback', context) def setup(self, session=None): """ diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 43b4687..90a00cb 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -124,16 +124,25 @@ class AppInfoView(MasterView): simple_settings = [ # basics - {'name': f'{self.app.appname}.app_title'}, - {'name': f'{self.app.appname}.node_type'}, - {'name': f'{self.app.appname}.node_title'}, - {'name': f'{self.app.appname}.production', + {'name': f'{self.config.appname}.app_title'}, + {'name': f'{self.config.appname}.node_type'}, + {'name': f'{self.config.appname}.node_title'}, + {'name': f'{self.config.appname}.production', 'type': bool}, # user/auth {'name': 'wuttaweb.home_redirect_to_login', 'type': bool, 'default': False}, + # email + {'name': f'{self.config.appname}.mail.send_emails', + 'type': bool, 'default': False}, + {'name': f'{self.config.appname}.email.default.sender'}, + {'name': f'{self.config.appname}.email.default.subject'}, + {'name': f'{self.config.appname}.email.default.to'}, + {'name': f'{self.config.appname}.email.feedback.subject'}, + {'name': f'{self.config.appname}.email.feedback.to'}, + ] def getval(key): diff --git a/tests/views/test_common.py b/tests/views/test_common.py index 9fe0074..0da2822 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -117,6 +117,16 @@ class TestCommonView(WebTestCase): self.assertEqual(list(context), ['error']) self.assertIn('RuntimeError', context['error']) + def test_feedback_send(self): + view = self.make_view() + with patch.object(self.app, 'send_email') as send_email: + view.feedback_send({'user_name': "Barney", + 'message': "hello world"}) + send_email.assert_called_once_with('feedback', { + 'user_name': "Barney", + 'message': "hello world" + }) + def test_setup(self): self.pyramid_config.add_route('home', '/') self.pyramid_config.add_route('login', '/login') From 0910153685d3ce077906edede88f9b667f9fdb45 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 14:27:20 -0500 Subject: [PATCH 9/9] =?UTF-8?q?bump:=20version=200.12.1=20=E2=86=92=200.13?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b0850..34f59cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to wuttaweb 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-08-26) + +### Feat + +- use native wuttjamaican app to send feedback email +- add basic user feedback email mechanism +- add "progress" page for executing upgrades +- add basic support for execute upgrades, download stdout/stderr +- add basic progress page/indicator support +- add basic "delete results" grid tool +- add initial views for upgrades +- allow app db to be rattail-native instead of wutta-native +- add per-row css class support for grids +- improve grid filter API a bit, support string/bool filters + +### Fix + +- tweak max image size for full logo on home, login pages +- improve handling of boolean form fields +- misc. improvements for display of grids, form errors +- use autocomplete for grid filter verb choices +- small cleanup for grid filters template +- add once-button action for grid Reset View +- set sort defaults for users, roles +- add override hook for base form template + ## v0.12.1 (2024-08-22) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 499bd04..fac1e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.12.1" +version = "0.13.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -42,7 +42,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db,email]>=0.12.1", + "WuttJamaican[db,email]>=0.13.0", "zope.sqlalchemy>=1.5", ]