3
0
Fork 0

feat: add basic master view class for batches

no support for displaying rows yet, just the main batch CRUD
This commit is contained in:
Lance Edgar 2024-12-13 22:20:04 -06:00
parent d72a2a15ec
commit 5006c97b4b
7 changed files with 516 additions and 2 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.views.batch``
========================
.. automodule:: wuttaweb.views.batch
:members:

View file

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

View file

@ -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
View 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()

View file

@ -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
##############################

View file

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