3
0
Fork 0

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:
Lance Edgar 2024-12-14 20:42:57 -06:00
parent e3beb9953d
commit dd1fd8c0ce
9 changed files with 404 additions and 11 deletions

View file

@ -32,6 +32,7 @@ requires-python = ">= 3.8"
dependencies = [ dependencies = [
"ColanderAlchemy", "ColanderAlchemy",
"humanize", "humanize",
"markdown",
"paginate", "paginate",
"paginate_sqlalchemy", "paginate_sqlalchemy",
"pyramid>=2", "pyramid>=2",

View file

@ -201,6 +201,10 @@
width: 100%; width: 100%;
} }
.tool-panels-wrapper {
padding: 1rem;
}
</style> </style>
</%def> </%def>

View 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>

View file

@ -1,6 +1,19 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/page.mako" /> <%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()"> <%def name="page_content()">
% if form is not Undefined: % if form is not Undefined:
<div class="wutta-form-wrapper"> <div class="wutta-form-wrapper">
@ -9,6 +22,14 @@
% endif % endif
</%def> </%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()"> <%def name="render_vue_template_form()">
% if form is not Undefined: % if form is not Undefined:
${form.render_vue_template()} ${form.render_vue_template()}

View file

@ -5,18 +5,32 @@
<%def name="content_title()">${instance_title}</%def> <%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: % if master.has_rows:
<br /> <div style="display: flex; flex-direction: column;">
<h4 class="block is-size-4">${master.get_rows_title() or ''}</h4> <div style="display: flex; justify-content: space-between;">
${rows_grid.render_vue_tag()}
% endif
## 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>
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">

View file

@ -1,6 +1,10 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/base.mako" /> <%inherit file="/base.mako" />
<%def name="page_layout()">
${self.page_content()}
</%def>
<%def name="page_content()"></%def> <%def name="page_content()"></%def>
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">
@ -12,7 +16,7 @@
<%def name="render_vue_template_this_page()"> <%def name="render_vue_template_this_page()">
<script type="text/x-template" id="this-page-template"> <script type="text/x-template" id="this-page-template">
<div class="wutta-page-content-wrapper"> <div class="wutta-page-content-wrapper">
${self.page_content()} ${self.page_layout()}
</div> </div>
</script> </script>
</%def> </%def>

View file

@ -6,6 +6,7 @@
${self.make_wutta_timepicker_component()} ${self.make_wutta_timepicker_component()}
${self.make_wutta_filter_component()} ${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()} ${self.make_wutta_filter_value_component()}
${self.make_wutta_tool_panel_component()}
</%def> </%def>
<%def name="make_wutta_request_mixin()"> <%def name="make_wutta_request_mixin()">
@ -477,3 +478,28 @@
</script> </script>
</%def> </%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>

View file

@ -28,6 +28,7 @@ import logging
import threading import threading
import time import time
import markdown
from sqlalchemy import orm from sqlalchemy import orm
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
@ -41,6 +42,13 @@ log = logging.getLogger(__name__)
class BatchMasterView(MasterView): class BatchMasterView(MasterView):
""" """
Base class for all "batch master" views. 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 = { labels = {
@ -66,6 +74,46 @@ class BatchMasterView(MasterView):
""" """
raise NotImplementedError 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): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)
@ -208,6 +256,20 @@ class BatchMasterView(MasterView):
thread.start() thread.start()
return self.render_progress(progress) 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): def populate_thread(self, batch_uuid, progress=None):
""" """
Thread target for populating new object with progress indicator. Thread target for populating new object with progress indicator.
@ -258,6 +320,31 @@ class BatchMasterView(MasterView):
finally: finally:
session.close() 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 # row methods
############################## ##############################
@ -287,3 +374,31 @@ class BatchMasterView(MasterView):
super().configure_row_grid(g) super().configure_row_grid(g)
g.set_label('sequence', "Seq.", column_only=True) 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}")

View file

@ -8,7 +8,7 @@ from pyramid.httpexceptions import HTTPFound
from wuttjamaican.db import model from wuttjamaican.db import model
from wuttjamaican.batch import BatchHandler 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 wuttaweb.progress import SessionProgress
from tests.util import WebTestCase from tests.util import WebTestCase
@ -34,6 +34,9 @@ class TestBatchMasterView(WebTestCase):
# nb. create MockBatch, MockBatchRow # nb. create MockBatch, MockBatchRow
model.Base.metadata.create_all(bind=self.session.bind) model.Base.metadata.create_all(bind=self.session.bind)
def make_handler(self):
return MockBatchHandler(self.config)
def make_view(self): def make_view(self):
return mod.BatchMasterView(self.request) return mod.BatchMasterView(self.request)
@ -44,6 +47,35 @@ class TestBatchMasterView(WebTestCase):
view = mod.BatchMasterView(self.request) view = mod.BatchMasterView(self.request)
self.assertEqual(view.batch_handler, 42) 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): def test_configure_grid(self):
handler = MockBatchHandler(self.config) handler = MockBatchHandler(self.config)
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch): with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
@ -162,6 +194,24 @@ class TestBatchMasterView(WebTestCase):
thread.start.assert_called_once_with() thread.start.assert_called_once_with()
self.assertTrue(render_progress.called) 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): def test_populate_thread(self):
model = self.app.model model = self.app.model
handler = MockBatchHandler(self.config) handler = MockBatchHandler(self.config)
@ -213,6 +263,35 @@ class TestBatchMasterView(WebTestCase):
# nb. should give up waiting after 1 second # nb. should give up waiting after 1 second
self.assertRaises(RuntimeError, view.populate_thread, batch.uuid) 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): def test_get_row_model_class(self):
handler = MockBatchHandler(self.config) handler = MockBatchHandler(self.config)
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): 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) grid = view.make_row_model_grid(batch)
self.assertIn('sequence', grid.labels) self.assertIn('sequence', grid.labels)
self.assertEqual(grid.labels['sequence'], "Seq.") 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)