diff --git a/docs/api/wuttaweb.views.batch.rst b/docs/api/wuttaweb.views.batch.rst new file mode 100644 index 0000000..8adc64b --- /dev/null +++ b/docs/api/wuttaweb.views.batch.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.batch`` +======================== + +.. automodule:: wuttaweb.views.batch + :members: diff --git a/docs/index.rst b/docs/index.rst index 7ece535..ce74ae6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 2c8a944..f90768a 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -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}' diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py new file mode 100644 index 0000000..da82503 --- /dev/null +++ b/src/wuttaweb/views/batch.py @@ -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 . +# +################################################################################ +""" +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() diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 4ce9d2d..7f94aba 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -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 ############################## diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index a49bdf5..cd3c7c4 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -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') diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py new file mode 100644 index 0000000..26ea706 --- /dev/null +++ b/tests/views/test_batch.py @@ -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)