feat: add basic master view class for batches
no support for displaying rows yet, just the main batch CRUD
This commit is contained in:
parent
d72a2a15ec
commit
5006c97b4b
6
docs/api/wuttaweb.views.batch.rst
Normal file
6
docs/api/wuttaweb.views.batch.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.batch``
|
||||
========================
|
||||
|
||||
.. automodule:: wuttaweb.views.batch
|
||||
:members:
|
|
@ -49,6 +49,7 @@ the narrative docs are pretty scant. That will eventually change.
|
|||
api/wuttaweb.views
|
||||
api/wuttaweb.views.auth
|
||||
api/wuttaweb.views.base
|
||||
api/wuttaweb.views.batch
|
||||
api/wuttaweb.views.common
|
||||
api/wuttaweb.views.essential
|
||||
api/wuttaweb.views.master
|
||||
|
|
|
@ -104,7 +104,7 @@ class ObjectRefWidget(SelectWidget):
|
|||
# add url, only if rendering readonly
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
if readonly:
|
||||
if 'url' not in values and self.url and hasattr(field.schema, 'model_instance'):
|
||||
if 'url' not in values and self.url and getattr(field.schema, 'model_instance', None):
|
||||
values['url'] = self.url(field.schema.model_instance)
|
||||
|
||||
return values
|
||||
|
@ -421,3 +421,22 @@ class PermissionsWidget(WuttaCheckboxChoiceWidget):
|
|||
kw['values'] = values
|
||||
|
||||
return super().serialize(field, cstruct, **kw)
|
||||
|
||||
|
||||
class BatchIdWidget(Widget):
|
||||
"""
|
||||
Widget for use with the
|
||||
:attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id`
|
||||
field of a :term:`batch` model.
|
||||
|
||||
This widget is "always" read-only and renders the Batch ID as
|
||||
zero-padded 8-char string
|
||||
"""
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct is colander.null:
|
||||
return colander.null
|
||||
|
||||
batch_id = int(cstruct)
|
||||
return f'{batch_id:08d}'
|
||||
|
|
255
src/wuttaweb/views/batch.py
Normal file
255
src/wuttaweb/views/batch.py
Normal file
|
@ -0,0 +1,255 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Base logic for Batch Master views
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from sqlalchemy import orm
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef
|
||||
from wuttaweb.forms.widgets import BatchIdWidget
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchMasterView(MasterView):
|
||||
"""
|
||||
Base class for all "batch master" views.
|
||||
"""
|
||||
|
||||
labels = {
|
||||
'id': "Batch ID",
|
||||
'status_code': "Batch Status",
|
||||
}
|
||||
|
||||
sort_defaults = ('id', 'desc')
|
||||
|
||||
def __init__(self, request, context=None):
|
||||
super().__init__(request, context=context)
|
||||
self.batch_handler = self.get_batch_handler()
|
||||
|
||||
def get_batch_handler(self):
|
||||
"""
|
||||
Must return the :term:`batch handler` for use with this view.
|
||||
|
||||
There is no default logic; subclass must override.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
model = self.app.model
|
||||
|
||||
# created_by
|
||||
CreatedBy = orm.aliased(model.User)
|
||||
g.set_joiner('created_by',
|
||||
lambda q: q.join(CreatedBy,
|
||||
CreatedBy.uuid == self.model_class.created_by_uuid))
|
||||
g.set_sorter('created_by', CreatedBy.username)
|
||||
# g.set_filter('created_by', CreatedBy.username, label="Created By Username")
|
||||
|
||||
# id
|
||||
g.set_renderer('id', self.render_batch_id)
|
||||
g.set_link('id')
|
||||
|
||||
# description
|
||||
g.set_link('description')
|
||||
|
||||
def render_batch_id(self, batch, key, value):
|
||||
""" """
|
||||
if value:
|
||||
batch_id = int(value)
|
||||
return f'{batch_id:08d}'
|
||||
|
||||
def get_instance_title(self, batch):
|
||||
""" """
|
||||
if batch.description:
|
||||
return f"{batch.id_str} {batch.description}"
|
||||
return batch.id_str
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
batch = f.model_instance
|
||||
|
||||
# id
|
||||
if self.creating:
|
||||
f.remove('id')
|
||||
else:
|
||||
f.set_readonly('id')
|
||||
f.set_widget('id', BatchIdWidget())
|
||||
|
||||
# notes
|
||||
f.set_widget('notes', 'notes')
|
||||
|
||||
# rows
|
||||
f.remove('rows')
|
||||
if self.creating:
|
||||
f.remove('row_count')
|
||||
else:
|
||||
f.set_readonly('row_count')
|
||||
|
||||
# status
|
||||
f.remove('status_text')
|
||||
if self.creating:
|
||||
f.remove('status_code')
|
||||
else:
|
||||
f.set_readonly('status_code')
|
||||
|
||||
# created
|
||||
if self.creating:
|
||||
f.remove('created')
|
||||
else:
|
||||
f.set_readonly('created')
|
||||
|
||||
# created_by
|
||||
f.remove('created_by_uuid')
|
||||
if self.creating:
|
||||
f.remove('created_by')
|
||||
else:
|
||||
f.set_node('created_by', UserRef(self.request))
|
||||
f.set_readonly('created_by')
|
||||
|
||||
# executed
|
||||
if self.creating or not batch.executed:
|
||||
f.remove('executed')
|
||||
else:
|
||||
f.set_readonly('executed')
|
||||
|
||||
# executed_by
|
||||
f.remove('executed_by_uuid')
|
||||
if self.creating or not batch.executed:
|
||||
f.remove('executed_by')
|
||||
else:
|
||||
f.set_node('executed_by', UserRef(self.request))
|
||||
f.set_readonly('executed_by')
|
||||
|
||||
def objectify(self, form):
|
||||
"""
|
||||
We override the default logic here, to invoke
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
|
||||
on the batch handler - when creating. Parent/default logic is
|
||||
used when updating.
|
||||
"""
|
||||
if self.creating:
|
||||
|
||||
# first get the "normal" objectified batch. this will have
|
||||
# all attributes set correctly per the form data, but will
|
||||
# not yet belong to the db session. we ultimately discard it.
|
||||
schema = form.get_schema()
|
||||
batch = schema.objectify(form.validated, context=form.model_instance)
|
||||
|
||||
# then we collect attributes from the new batch
|
||||
kwargs = dict([(key, getattr(batch, key))
|
||||
for key in form.validated
|
||||
if hasattr(batch, key)])
|
||||
|
||||
# and set attribute for user creating the batch
|
||||
kwargs['created_by'] = self.request.user
|
||||
|
||||
# finally let batch handler make the "real" batch
|
||||
return self.batch_handler.make_batch(self.Session(), **kwargs)
|
||||
|
||||
# when not creating, normal logic is fine
|
||||
return super().objectify(form)
|
||||
|
||||
def redirect_after_create(self, batch):
|
||||
"""
|
||||
If the new batch requires initial population, we launch a
|
||||
thread for that and show the "progress" page.
|
||||
|
||||
Otherwise this will do the normal thing of redirecting to the
|
||||
"view" page for the new batch.
|
||||
"""
|
||||
# just view batch if should not populate
|
||||
if not self.batch_handler.should_populate(batch):
|
||||
return self.redirect(self.get_action_url('view', batch))
|
||||
|
||||
# setup thread to populate batch
|
||||
route_prefix = self.get_route_prefix()
|
||||
key = f'{route_prefix}.populate'
|
||||
progress = self.make_progress(key, success_url=self.get_action_url('view', batch))
|
||||
thread = threading.Thread(target=self.populate_thread,
|
||||
args=(batch.uuid,),
|
||||
kwargs=dict(progress=progress))
|
||||
|
||||
# start thread and show progress page
|
||||
thread.start()
|
||||
return self.render_progress(progress)
|
||||
|
||||
def populate_thread(self, batch_uuid, progress=None):
|
||||
"""
|
||||
Thread target for populating new object with progress indicator.
|
||||
|
||||
When a new batch is created, and the batch handler says it
|
||||
should also be populated, then this thread is launched to do
|
||||
so outside of the main request/response cycle. Progress bar
|
||||
is then shown to the user until it completes.
|
||||
|
||||
This method mostly just calls
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
|
||||
on the :term:`batch handler`.
|
||||
"""
|
||||
# nb. must use our own session in separate thread
|
||||
session = self.app.make_session()
|
||||
|
||||
# nb. main web request which created the batch, must complete
|
||||
# before that session is committed. until that happens we
|
||||
# will not be able to see the new batch. hence this loop,
|
||||
# where we wait for the batch to appear.
|
||||
batch = None
|
||||
tries = 0
|
||||
while not batch:
|
||||
batch = session.get(self.model_class, batch_uuid)
|
||||
tries += 1
|
||||
if tries > 10:
|
||||
raise RuntimeError("can't find the batch")
|
||||
time.sleep(0.1)
|
||||
|
||||
try:
|
||||
# populate the batch
|
||||
self.batch_handler.do_populate(batch, progress=progress)
|
||||
session.flush()
|
||||
|
||||
except Exception as error:
|
||||
session.rollback()
|
||||
log.warning("failed to populate %s: %s",
|
||||
self.get_model_title(), batch,
|
||||
exc_info=True)
|
||||
if progress:
|
||||
progress.handle_error(error)
|
||||
|
||||
else:
|
||||
session.commit()
|
||||
if progress:
|
||||
progress.handle_success()
|
||||
|
||||
finally:
|
||||
session.close()
|
|
@ -458,6 +458,7 @@ class MasterView(View):
|
|||
* :meth:`make_model_form()`
|
||||
* :meth:`configure_form()`
|
||||
* :meth:`create_save_form()`
|
||||
* :meth:`redirect_after_create()`
|
||||
"""
|
||||
self.creating = True
|
||||
form = self.make_model_form(cancel_url_fallback=self.get_index_url())
|
||||
|
@ -465,7 +466,7 @@ class MasterView(View):
|
|||
if form.validate():
|
||||
obj = self.create_save_form(form)
|
||||
self.Session.flush()
|
||||
return self.redirect(self.get_action_url('view', obj))
|
||||
return self.redirect_after_create(obj)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
|
@ -491,6 +492,16 @@ class MasterView(View):
|
|||
self.persist(obj)
|
||||
return obj
|
||||
|
||||
def redirect_after_create(self, obj):
|
||||
"""
|
||||
Usually, this returns a redirect to which we send the user,
|
||||
after a new model record has been created. By default this
|
||||
sends them to the "view" page for the record.
|
||||
|
||||
It is called automatically by :meth:`create()`.
|
||||
"""
|
||||
return self.redirect(self.get_action_url('view', obj))
|
||||
|
||||
##############################
|
||||
# view methods
|
||||
##############################
|
||||
|
|
|
@ -302,3 +302,23 @@ class TestPermissionsWidget(WebTestCase):
|
|||
# editable output always includes the perm
|
||||
html = widget.serialize(field, set())
|
||||
self.assertIn("Polish the widgets", html)
|
||||
|
||||
|
||||
class TestBatchIdWidget(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):
|
||||
node = colander.SchemaNode(colander.Integer())
|
||||
field = self.make_field(node)
|
||||
widget = mod.BatchIdWidget()
|
||||
|
||||
result = widget.serialize(field, colander.null)
|
||||
self.assertIs(result, colander.null)
|
||||
|
||||
result = widget.serialize(field, 42)
|
||||
self.assertEqual(result, '00000042')
|
||||
|
|
202
tests/views/test_batch.py
Normal file
202
tests/views/test_batch.py
Normal file
|
@ -0,0 +1,202 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from wuttjamaican.db import model
|
||||
from wuttjamaican.batch import BatchHandler
|
||||
from wuttaweb.views import batch as mod
|
||||
from wuttaweb.progress import SessionProgress
|
||||
from tests.util import WebTestCase
|
||||
|
||||
|
||||
class MockBatch(model.BatchMixin, model.Base):
|
||||
__tablename__ = 'testing_batch_mock'
|
||||
|
||||
class MockBatchRow(model.BatchRowMixin, model.Base):
|
||||
__tablename__ = 'testing_batch_mock_row'
|
||||
__batch_class__ = MockBatch
|
||||
|
||||
class MockBatchHandler(BatchHandler):
|
||||
model_class = MockBatch
|
||||
|
||||
|
||||
class TestBatchMasterView(WebTestCase):
|
||||
|
||||
def test_get_batch_handler(self):
|
||||
self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request)
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=42):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
self.assertEqual(view.batch_handler, 42)
|
||||
|
||||
def test_configure_grid(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
grid = view.make_model_grid()
|
||||
# nb. coverage only; tests nothing
|
||||
view.configure_grid(grid)
|
||||
|
||||
def test_render_batch_id(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
result = view.render_batch_id(batch, 'id', 42)
|
||||
self.assertEqual(result, '00000042')
|
||||
|
||||
result = view.render_batch_id(batch, 'id', None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_instance_title(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
batch = MockBatch(id=42)
|
||||
result = view.get_instance_title(batch)
|
||||
self.assertEqual(result, "00000042")
|
||||
|
||||
batch = MockBatch(id=43, description="runnin some numbers")
|
||||
result = view.get_instance_title(batch)
|
||||
self.assertEqual(result, "00000043 runnin some numbers")
|
||||
|
||||
def test_configure_form(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
# creating
|
||||
with patch.object(view, 'creating', new=True):
|
||||
form = view.make_model_form(model_instance=None)
|
||||
view.configure_form(form)
|
||||
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
# viewing
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# editing
|
||||
with patch.object(view, 'editing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# deleting
|
||||
with patch.object(view, 'deleting', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# viewing (executed)
|
||||
batch.executed = datetime.datetime.now()
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
def test_objectify(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.object(mod.BatchMasterView, 'Session', return_value=self.session):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
# create batch
|
||||
with patch.object(view, 'creating', new=True):
|
||||
form = view.make_model_form(model_instance=None)
|
||||
form.validated = {}
|
||||
batch = view.objectify(form)
|
||||
self.assertIsInstance(batch.id, int)
|
||||
self.assertTrue(batch.id > 0)
|
||||
|
||||
# edit batch
|
||||
with patch.object(view, 'editing', new=True):
|
||||
with patch.object(view.batch_handler, 'make_batch') as make_batch:
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
form.validated = {'description': 'foo'}
|
||||
self.assertIsNone(batch.description)
|
||||
batch = view.objectify(form)
|
||||
self.assertEqual(batch.description, 'foo')
|
||||
|
||||
def test_redirect_after_create(self):
|
||||
self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}')
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.multiple(mod.BatchMasterView, create=True,
|
||||
model_class=MockBatch,
|
||||
route_prefix='mock_batches'):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
# typically redirect to view batch
|
||||
result = view.redirect_after_create(batch)
|
||||
self.assertIsInstance(result, HTTPFound)
|
||||
|
||||
# unless populating in which case thread is launched
|
||||
self.request.session.id = 'abcdefghijk'
|
||||
with patch.object(mod, 'threading') as threading:
|
||||
thread = MagicMock()
|
||||
threading.Thread.return_value = thread
|
||||
with patch.object(view.batch_handler, 'should_populate', return_value=True):
|
||||
with patch.object(view, 'render_progress') as render_progress:
|
||||
view.redirect_after_create(batch)
|
||||
self.assertTrue(threading.Thread.called)
|
||||
thread.start.assert_called_once_with()
|
||||
self.assertTrue(render_progress.called)
|
||||
|
||||
def test_populate_thread(self):
|
||||
model = self.app.model
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = MockBatch(id=42, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.commit()
|
||||
|
||||
# nb. use our session within thread method
|
||||
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||
|
||||
# nb. prevent closing our session
|
||||
with patch.object(self.session, 'close') as close:
|
||||
|
||||
# without progress
|
||||
view.populate_thread(batch.uuid)
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# with progress
|
||||
self.request.session.id = 'abcdefghijk'
|
||||
view.populate_thread(batch.uuid,
|
||||
progress=SessionProgress(self.request,
|
||||
'populate_mock_batch'))
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure to populate, without progress
|
||||
with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
|
||||
view.populate_thread(batch.uuid)
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure to populate, with progress
|
||||
with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
|
||||
view.populate_thread(batch.uuid,
|
||||
progress=SessionProgress(self.request,
|
||||
'populate_mock_batch'))
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure for batch to appear
|
||||
self.session.delete(batch)
|
||||
self.session.commit()
|
||||
# nb. should give up waiting after 1 second
|
||||
self.assertRaises(RuntimeError, view.populate_thread, batch.uuid)
|
Loading…
Reference in a new issue