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;
+ }
+
%def>
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>
+
+<%def name="tool_panels()">
+ ${parent.tool_panels()}
+ ${self.tool_panel_execution()}
+%def>
+
+<%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
+
+
+
+
+
+
+ Execute ${model_title}
+
+
+ ## TODO: forcing black text b/c of b-notification
+ ## wrapping button, which has white text
+
+
+ What will happen when this batch is executed?
+
+
+ ${execution_described|n}
+
+ ${h.form(master.get_action_url('execute', batch), ref='executeForm')}
+ ${h.csrf_token(request)}
+ ${h.end_form()}
+
+
+
+
+
+
+
+
+ % else:
+
+
+ Batch may be executed,
+ but you do not have permission.
+
+
+ % endif
+ % endif
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if not batch.executed and not why_not_execute and master.has_perm('execute'):
+
+ % endif
+%def>
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()">
+
@@ -9,6 +22,14 @@
% endif
%def>
+<%def name="tool_panels_wrapper()">
+
+ ${self.tool_panels()}
+
+%def>
+
+<%def name="tool_panels()">%def>
+
<%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>
-<%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>
<%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>
+
<%def name="page_content()">%def>
<%def name="render_vue_templates()">
@@ -12,7 +16,7 @@
<%def name="render_vue_template_this_page()">
%def>
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>
<%def name="make_wutta_request_mixin()">
@@ -477,3 +478,28 @@
%def>
+
+<%def name="make_wutta_tool_panel_component()">
+
+
+%def>
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)