feat: add basic support for batch execution
no execution options yet, and no progress indicator also basic delete support, invoking handler
This commit is contained in:
parent
e3beb9953d
commit
dd1fd8c0ce
|
@ -32,6 +32,7 @@ requires-python = ">= 3.8"
|
|||
dependencies = [
|
||||
"ColanderAlchemy",
|
||||
"humanize",
|
||||
"markdown",
|
||||
"paginate",
|
||||
"paginate_sqlalchemy",
|
||||
"pyramid>=2",
|
||||
|
|
|
@ -201,6 +201,10 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panels-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
|
|
124
src/wuttaweb/templates/batch/view.mako
Normal file
124
src/wuttaweb/templates/batch/view.mako
Normal file
|
@ -0,0 +1,124 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/view.mako" />
|
||||
|
||||
<%def name="extra_styles()">
|
||||
${parent.extra_styles()}
|
||||
<style>
|
||||
|
||||
## TODO: should we do something like this site-wide?
|
||||
## (so far this is the only place we use markdown)
|
||||
.markdown p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels()">
|
||||
${parent.tool_panels()}
|
||||
${self.tool_panel_execution()}
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panel_execution()">
|
||||
<wutta-tool-panel heading="Execution">
|
||||
% if batch.executed:
|
||||
<b-notification :closable="false">
|
||||
<p class="block">
|
||||
Batch was executed<br />
|
||||
${app.render_time_ago(batch.executed)}
|
||||
by ${batch.executed_by}.
|
||||
</p>
|
||||
</b-notification>
|
||||
% elif why_not_execute:
|
||||
<b-notification type="is-warning" :closable="false">
|
||||
<p class="block">
|
||||
Batch cannot be executed:
|
||||
</p>
|
||||
<p class="block">
|
||||
${why_not_execute}
|
||||
</p>
|
||||
</b-notification>
|
||||
% else:
|
||||
% if master.has_perm('execute'):
|
||||
<b-notification type="is-success" :closable="false">
|
||||
<p class="block">
|
||||
Batch can be executed.
|
||||
</p>
|
||||
<b-button type="is-primary"
|
||||
@click="executeInit()"
|
||||
icon-pack="fas"
|
||||
icon-left="arrow-circle-right">
|
||||
Execute Batch
|
||||
</b-button>
|
||||
|
||||
<b-modal has-modal-card
|
||||
:active.sync="executeShowDialog">
|
||||
<div class="modal-card">
|
||||
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Execute ${model_title}</p>
|
||||
</header>
|
||||
|
||||
## TODO: forcing black text b/c of b-notification
|
||||
## wrapping button, which has white text
|
||||
<section class="modal-card-body has-text-black">
|
||||
<p class="block has-text-weight-bold">
|
||||
What will happen when this batch is executed?
|
||||
</p>
|
||||
<div class="markdown">
|
||||
${execution_described|n}
|
||||
</div>
|
||||
${h.form(master.get_action_url('execute', batch), ref='executeForm')}
|
||||
${h.csrf_token(request)}
|
||||
${h.end_form()}
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="executeShowDialog = false">
|
||||
Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
@click="executeSubmit()"
|
||||
icon-pack="fas"
|
||||
icon-left="arrow-circle-right"
|
||||
:disabled="executeSubmitting">
|
||||
{{ executeSubmitting ? "Working, please wait..." : "Execute Batch" }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</b-modal>
|
||||
</b-notification>
|
||||
|
||||
% else:
|
||||
<b-notification type="is-warning" :closable="false">
|
||||
<p class="block">
|
||||
Batch may be executed,<br />
|
||||
but you do not have permission.
|
||||
</p>
|
||||
</b-notification>
|
||||
% endif
|
||||
% endif
|
||||
</wutta-tool-panel>
|
||||
</%def>
|
||||
|
||||
<%def name="modify_vue_vars()">
|
||||
${parent.modify_vue_vars()}
|
||||
% if not batch.executed and not why_not_execute and master.has_perm('execute'):
|
||||
<script>
|
||||
|
||||
ThisPageData.executeShowDialog = false
|
||||
ThisPageData.executeSubmitting = false
|
||||
|
||||
ThisPage.methods.executeInit = function() {
|
||||
this.executeShowDialog = true
|
||||
}
|
||||
|
||||
ThisPage.methods.executeSubmit = function() {
|
||||
this.executeSubmitting = true
|
||||
this.$refs.executeForm.submit()
|
||||
}
|
||||
|
||||
</script>
|
||||
% endif
|
||||
</%def>
|
|
@ -1,6 +1,19 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/page.mako" />
|
||||
|
||||
<%def name="page_layout()">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
## main form
|
||||
<div style="flex-grow: 1;">
|
||||
${self.page_content()}
|
||||
</div>
|
||||
|
||||
## tool panels
|
||||
${self.tool_panels_wrapper()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
<%def name="page_content()">
|
||||
% if form is not Undefined:
|
||||
<div class="wutta-form-wrapper">
|
||||
|
@ -9,6 +22,14 @@
|
|||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels_wrapper()">
|
||||
<div class="tool-panels-wrapper">
|
||||
${self.tool_panels()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels()"></%def>
|
||||
|
||||
<%def name="render_vue_template_form()">
|
||||
% if form is not Undefined:
|
||||
${form.render_vue_template()}
|
||||
|
|
|
@ -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:
|
||||
<br />
|
||||
<h4 class="block is-size-4">${master.get_rows_title() or ''}</h4>
|
||||
${rows_grid.render_vue_tag()}
|
||||
% endif
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
## main form
|
||||
<div style="flex-grow: 1;">
|
||||
${self.page_content()}
|
||||
</div>
|
||||
|
||||
## tool panels
|
||||
${self.tool_panels_wrapper()}
|
||||
|
||||
</div>
|
||||
|
||||
## rows grid
|
||||
<br />
|
||||
<h4 class="block is-size-4">${master.get_rows_title() or ''}</h4>
|
||||
${rows_grid.render_vue_tag()}
|
||||
</div>
|
||||
|
||||
% else:
|
||||
## no rows, just main form + tool panels
|
||||
${parent.page_layout()}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="render_vue_templates()">
|
||||
|
|
|
@ -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()">
|
||||
<script type="text/x-template" id="this-page-template">
|
||||
<div class="wutta-page-content-wrapper">
|
||||
${self.page_content()}
|
||||
${self.page_layout()}
|
||||
</div>
|
||||
</script>
|
||||
</%def>
|
||||
|
|
|
@ -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 @@
|
|||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_tool_panel_component()">
|
||||
<script type="text/x-template" id="wutta-tool-panel-template">
|
||||
<nav class="panel tool-panel">
|
||||
<p class="panel-heading">{{ heading }}</p>
|
||||
<div class="panel-block">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
const WuttaToolPanel = {
|
||||
template: '#wutta-tool-panel-template',
|
||||
props: {
|
||||
heading: String,
|
||||
},
|
||||
}
|
||||
|
||||
Vue.component('wutta-tool-panel', WuttaToolPanel)
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue