From dd1fd8c0ce1a050c63a035c310dc67bcf6afb5f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Dec 2024 20:42:57 -0600 Subject: [PATCH] feat: add basic support for batch execution no execution options yet, and no progress indicator also basic delete support, invoking handler --- pyproject.toml | 1 + src/wuttaweb/templates/base.mako | 4 + src/wuttaweb/templates/batch/view.mako | 124 +++++++++++++++++++ src/wuttaweb/templates/form.mako | 21 ++++ src/wuttaweb/templates/master/view.mako | 32 +++-- src/wuttaweb/templates/page.mako | 6 +- src/wuttaweb/templates/wutta-components.mako | 26 ++++ src/wuttaweb/views/batch.py | 115 +++++++++++++++++ tests/views/test_batch.py | 86 ++++++++++++- 9 files changed, 404 insertions(+), 11 deletions(-) create mode 100644 src/wuttaweb/templates/batch/view.mako diff --git a/pyproject.toml b/pyproject.toml index 0a0435c..3a7c5cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", "humanize", + "markdown", "paginate", "paginate_sqlalchemy", "pyramid>=2", diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index a33d3ca..df05cbb 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -201,6 +201,10 @@ width: 100%; } + .tool-panels-wrapper { + padding: 1rem; + } + diff --git a/src/wuttaweb/templates/batch/view.mako b/src/wuttaweb/templates/batch/view.mako new file mode 100644 index 0000000..569af5b --- /dev/null +++ b/src/wuttaweb/templates/batch/view.mako @@ -0,0 +1,124 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="tool_panels()"> + ${parent.tool_panels()} + ${self.tool_panel_execution()} + + +<%def name="tool_panel_execution()"> + + % if batch.executed: + +

+ Batch was executed
+ ${app.render_time_ago(batch.executed)} + by ${batch.executed_by}. +

+
+ % elif why_not_execute: + +

+ Batch cannot be executed: +

+

+ ${why_not_execute} +

+
+ % else: + % if master.has_perm('execute'): + +

+ Batch can be executed. +

+ + Execute Batch + + + + + +
+ + % else: + +

+ Batch may be executed,
+ but you do not have permission. +

+
+ % endif + % endif +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if not batch.executed and not why_not_execute and master.has_perm('execute'): + + % endif + diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako index de7209a..1a4fe2d 100644 --- a/src/wuttaweb/templates/form.mako +++ b/src/wuttaweb/templates/form.mako @@ -1,6 +1,19 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> +<%def name="page_layout()"> +
+ + ## main form +
+ ${self.page_content()} +
+ + ## tool panels + ${self.tool_panels_wrapper()} +
+ + <%def name="page_content()"> % if form is not Undefined:
@@ -9,6 +22,14 @@ % endif +<%def name="tool_panels_wrapper()"> +
+ ${self.tool_panels()} +
+ + +<%def name="tool_panels()"> + <%def name="render_vue_template_form()"> % if form is not Undefined: ${form.render_vue_template()} diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako index c7021eb..b4db013 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -5,18 +5,32 @@ <%def name="content_title()">${instance_title} -<%def name="page_content()"> +<%def name="page_layout()"> - ## render main form - ${parent.page_content()} - - ## render row grid % if master.has_rows: -
-

${master.get_rows_title() or ''}

- ${rows_grid.render_vue_tag()} - % endif +
+
+ ## main form +
+ ${self.page_content()} +
+ + ## tool panels + ${self.tool_panels_wrapper()} + +
+ + ## rows grid +
+

${master.get_rows_title() or ''}

+ ${rows_grid.render_vue_tag()} +
+ + % else: + ## no rows, just main form + tool panels + ${parent.page_layout()} + % endif <%def name="render_vue_templates()"> diff --git a/src/wuttaweb/templates/page.mako b/src/wuttaweb/templates/page.mako index 218e9f4..c23ce90 100644 --- a/src/wuttaweb/templates/page.mako +++ b/src/wuttaweb/templates/page.mako @@ -1,6 +1,10 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> +<%def name="page_layout()"> + ${self.page_content()} + + <%def name="page_content()"> <%def name="render_vue_templates()"> @@ -12,7 +16,7 @@ <%def name="render_vue_template_this_page()"> diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index 6030840..5664933 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -6,6 +6,7 @@ ${self.make_wutta_timepicker_component()} ${self.make_wutta_filter_component()} ${self.make_wutta_filter_value_component()} + ${self.make_wutta_tool_panel_component()} <%def name="make_wutta_request_mixin()"> @@ -477,3 +478,28 @@ + +<%def name="make_wutta_tool_panel_component()"> + + + diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py index 1383ec9..1645455 100644 --- a/src/wuttaweb/views/batch.py +++ b/src/wuttaweb/views/batch.py @@ -28,6 +28,7 @@ import logging import threading import time +import markdown from sqlalchemy import orm from wuttaweb.views import MasterView @@ -41,6 +42,13 @@ log = logging.getLogger(__name__) class BatchMasterView(MasterView): """ Base class for all "batch master" views. + + .. attribute:: batch_handler + + Reference to the :term:`batch handler` for use with the view. + + This is set when the view is first created, using return value + from :meth:`get_batch_handler()`. """ labels = { @@ -66,6 +74,46 @@ class BatchMasterView(MasterView): """ raise NotImplementedError + def get_fallback_templates(self, template): + """ + We override the default logic here, to prefer "batch" + templates over the "master" templates. + + So for instance the "view batch" page will by default use the + ``/batch/view.mako`` template - which does inherit from + ``/master/view.mako`` but adds extra features specific to + batches. + """ + templates = super().get_fallback_templates(template) + templates.insert(0, f'/batch/{template}.mako') + return templates + + def render_to_response(self, template, context): + """ + We override the default logic here, to inject batch-related + context for the + :meth:`~wuttaweb.views.master.MasterView.view()` template + specifically. These values are used in the template file, + ``/batch/view.mako``. + + * ``batch`` - reference to the current :term:`batch` + * ``batch_handler`` reference to :attr:`batch_handler` + * ``why_not_execute`` - text of reason (if any) not to execute batch + * ``execution_described`` - HTML (rendered from markdown) describing batch execution + """ + if template == 'view': + batch = context['instance'] + context['batch'] = batch + context['batch_handler'] = self.batch_handler + context['why_not_execute'] = self.batch_handler.why_not_execute(batch) + + description = (self.batch_handler.describe_execution(batch) + or "Handler does not say! Your guess is as good as mine.") + context['execution_described'] = markdown.markdown( + description, extensions=['fenced_code', 'codehilite']) + + return super().render_to_response(template, context) + def configure_grid(self, g): """ """ super().configure_grid(g) @@ -208,6 +256,20 @@ class BatchMasterView(MasterView): thread.start() return self.render_progress(progress) + def delete_instance(self, batch): + """ + Delete the given batch instance. + + This calls + :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()` + on the :attr:`batch_handler`. + """ + self.batch_handler.do_delete(batch, self.request.user) + + ############################## + # populate methods + ############################## + def populate_thread(self, batch_uuid, progress=None): """ Thread target for populating new object with progress indicator. @@ -258,6 +320,31 @@ class BatchMasterView(MasterView): finally: session.close() + ############################## + # execute methods + ############################## + + def execute(self): + """ + View to execute the current :term:`batch`. + + Eventually this should show a progress indicator etc., but for + now it simply calls + :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()` + on the :attr:`batch_handler` and waits for it to complete, + then redirects user back to the "view batch" page. + """ + self.executing = True + batch = self.get_instance() + + try: + self.batch_handler.do_execute(batch, self.request.user) + except Exception as error: + log.warning("failed to execute batch: %s", batch, exc_info=True) + self.request.session.flash(f"Execution failed!: {error}", 'error') + + return self.redirect(self.get_action_url('view', batch)) + ############################## # row methods ############################## @@ -287,3 +374,31 @@ class BatchMasterView(MasterView): super().configure_row_grid(g) g.set_label('sequence', "Seq.", column_only=True) + + ############################## + # configuration + ############################## + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._batch_defaults(config) + + @classmethod + def _batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + instance_url_prefix = cls.get_instance_url_prefix() + + # execute + 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}") diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index fb2f46b..a3a34fe 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -8,7 +8,7 @@ from pyramid.httpexceptions import HTTPFound from wuttjamaican.db import model from wuttjamaican.batch import BatchHandler -from wuttaweb.views import batch as mod +from wuttaweb.views import MasterView, batch as mod from wuttaweb.progress import SessionProgress from tests.util import WebTestCase @@ -34,6 +34,9 @@ class TestBatchMasterView(WebTestCase): # nb. create MockBatch, MockBatchRow model.Base.metadata.create_all(bind=self.session.bind) + def make_handler(self): + return MockBatchHandler(self.config) + def make_view(self): return mod.BatchMasterView(self.request) @@ -44,6 +47,35 @@ class TestBatchMasterView(WebTestCase): view = mod.BatchMasterView(self.request) self.assertEqual(view.batch_handler, 42) + def test_get_fallback_templates(self): + handler = MockBatchHandler(self.config) + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + view = self.make_view() + templates = view.get_fallback_templates('view') + self.assertEqual(templates, [ + '/batch/view.mako', + '/master/view.mako', + ]) + + def test_render_to_response(self): + model = self.app.model + handler = MockBatchHandler(self.config) + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + with patch.object(MasterView, 'render_to_response') as render_to_response: + view = self.make_view() + response = view.render_to_response('view', {'instance': batch}) + self.assertTrue(render_to_response.called) + context = render_to_response.call_args[0][1] + self.assertIs(context['batch'], batch) + self.assertIs(context['batch_handler'], handler) + def test_configure_grid(self): handler = MockBatchHandler(self.config) with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch): @@ -162,6 +194,24 @@ class TestBatchMasterView(WebTestCase): thread.start.assert_called_once_with() self.assertTrue(render_progress.called) + def test_delete_instance(self): + model = self.app.model + handler = self.make_handler() + + user = model.User(username='barney') + self.session.add(user) + + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.flush() + + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + view = self.make_view() + + self.assertEqual(self.session.query(MockBatch).count(), 1) + view.delete_instance(batch) + self.assertEqual(self.session.query(MockBatch).count(), 0) + def test_populate_thread(self): model = self.app.model handler = MockBatchHandler(self.config) @@ -213,6 +263,35 @@ class TestBatchMasterView(WebTestCase): # nb. should give up waiting after 1 second self.assertRaises(RuntimeError, view.populate_thread, batch.uuid) + def test_execute(self): + self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}') + model = self.app.model + handler = MockBatchHandler(self.config) + + user = model.User(username='barney') + self.session.add(user) + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + self.session.commit() + + with patch.multiple(mod.BatchMasterView, create=True, + model_class=MockBatch, + route_prefix='mock_batches', + get_batch_handler=MagicMock(return_value=handler), + get_instance=MagicMock(return_value=batch)): + view = self.make_view() + + # batch executes okay + response = view.execute() + self.assertEqual(response.status_code, 302) # redirect to "view batch" + self.assertFalse(self.request.session.peek_flash('error')) + + # but cannot be executed again + response = view.execute() + self.assertEqual(response.status_code, 302) # redirect to "view batch" + # nb. flash has error this time + self.assertTrue(self.request.session.peek_flash('error')) + def test_get_row_model_class(self): handler = MockBatchHandler(self.config) with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): @@ -287,3 +366,8 @@ class TestBatchMasterView(WebTestCase): grid = view.make_row_model_grid(batch) self.assertIn('sequence', grid.labels) self.assertEqual(grid.labels['sequence'], "Seq.") + + def test_defaults(self): + # nb. coverage only + with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True): + mod.BatchMasterView.defaults(self.pyramid_config)