From 5006c97b4bf16ba52be3e611071fce9a3b3c22c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 13 Dec 2024 22:20:04 -0600 Subject: [PATCH 1/5] feat: add basic master view class for batches no support for displaying rows yet, just the main batch CRUD --- docs/api/wuttaweb.views.batch.rst | 6 + docs/index.rst | 1 + src/wuttaweb/forms/widgets.py | 21 ++- src/wuttaweb/views/batch.py | 255 ++++++++++++++++++++++++++++++ src/wuttaweb/views/master.py | 13 +- tests/forms/test_widgets.py | 20 +++ tests/views/test_batch.py | 202 +++++++++++++++++++++++ 7 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 docs/api/wuttaweb.views.batch.rst create mode 100644 src/wuttaweb/views/batch.py create mode 100644 tests/views/test_batch.py 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) From e3beb9953d50e0b0987ed1eabc8a9fab2ad9a27e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Dec 2024 10:38:49 -0600 Subject: [PATCH 2/5] feat: add basic support for rows grid for master, batch views --- src/wuttaweb/templates/master/view.mako | 31 ++- src/wuttaweb/views/batch.py | 34 +++ src/wuttaweb/views/master.py | 275 +++++++++++++++++++++++- tests/views/test_batch.py | 87 ++++++++ tests/views/test_master.py | 157 ++++++++++++++ 5 files changed, 573 insertions(+), 11 deletions(-) diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako index b84ebc1..c7021eb 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -5,5 +5,34 @@ <%def name="content_title()">${instance_title} +<%def name="page_content()"> -${parent.body()} + ## render main form + ${parent.page_content()} + + ## render row grid + % if master.has_rows: +
+

${master.get_rows_title() or ''}

+ ${rows_grid.render_vue_tag()} + % endif + + + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_rows: + ${self.render_vue_template_rows_grid()} + % endif + + +<%def name="render_vue_template_rows_grid()"> + ${rows_grid.render_vue_template()} + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if master.has_rows: + ${rows_grid.render_vue_finalize()} + % endif + diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py index da82503..1383ec9 100644 --- a/src/wuttaweb/views/batch.py +++ b/src/wuttaweb/views/batch.py @@ -50,6 +50,10 @@ class BatchMasterView(MasterView): sort_defaults = ('id', 'desc') + has_rows = True + rows_title = "Batch Rows" + rows_sort_defaults = 'sequence' + def __init__(self, request, context=None): super().__init__(request, context=context) self.batch_handler = self.get_batch_handler() @@ -253,3 +257,33 @@ class BatchMasterView(MasterView): finally: session.close() + + ############################## + # row methods + ############################## + + @classmethod + def get_row_model_class(cls): + """ """ + if hasattr(cls, 'row_model_class'): + return cls.row_model_class + + Batch = cls.get_model_class() + return Batch.__row_class__ + + def get_row_grid_data(self, batch): + """ + Returns the base query for the batch + :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows` + data. + """ + BatchRow = self.get_row_model_class() + query = self.Session.query(BatchRow)\ + .filter(BatchRow.batch == batch) + return query + + def configure_row_grid(self, g): + """ """ + super().configure_row_grid(g) + + g.set_label('sequence', "Seq.", column_only=True) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 7f94aba..0030859 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -73,12 +73,12 @@ class MasterView(View): .. attribute:: model_class - Optional reference to a data model class. While not strictly - required, most views will set this to a SQLAlchemy mapped - class, + Optional reference to a :term:`data model` class. While not + strictly required, most views will set this to a SQLAlchemy + mapped class, e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. - Code should not access this directly but instead call + The base logic should not access this directly but instead call :meth:`get_model_class()`. .. attribute:: model_name @@ -340,6 +340,38 @@ class MasterView(View): Boolean indicating whether the master view supports "configuring" - i.e. it should have a :meth:`configure()` view. Default value is ``False``. + + **ROW FEATURES** + + .. attribute:: has_rows + + Whether the model has "rows" which should also be displayed + when viewing model records. + + This the "master switch" for all row features; if this is turned + on then many other things kick in. + + See also :attr:`row_model_class`. + + .. attribute:: row_model_class + + Reference to a :term:`data model` class for the rows. + + The base logic should not access this directly but instead call + :meth:`get_row_model_class()`. + + .. attribute:: rows_title + + Display title for the rows grid. + + The base logic should not access this directly but instead call + :meth:`get_rows_title()`. + + .. attribute:: row_grid_columns + + List of columns for the row grid. + + This is optional; see also :meth:`get_row_grid_columns()`. """ ############################## @@ -368,6 +400,16 @@ class MasterView(View): execute_progress_template = None configurable = False + # row features + has_rows = False + rows_filterable = True + rows_filter_defaults = None + rows_sortable = True + rows_sort_on_backend = True + rows_sort_defaults = None + rows_paginated = True + rows_paginate_on_backend = True + # current action listing = False creating = False @@ -525,15 +567,40 @@ class MasterView(View): * :meth:`make_model_form()` * :meth:`configure_form()` + * :meth:`make_row_model_grid()` - if :attr:`has_rows` is true """ self.viewing = True - instance = self.get_instance() - form = self.make_model_form(instance, readonly=True) - + obj = self.get_instance() + form = self.make_model_form(obj, readonly=True) context = { - 'instance': instance, + 'instance': obj, 'form': form, } + + if self.has_rows: + + # always make the grid first. note that it already knows + # to "reset" its params when that is requested. + grid = self.make_row_model_grid(obj) + + # but if user did request a "reset" then we want to + # redirect so the query string gets cleared out + if self.request.GET.get('reset-view'): + + # nb. we want to preserve url hash if applicable + kw = {'_query': None, + '_anchor': self.request.GET.get('hash')} + return self.redirect(self.request.current_route_url(**kw)) + + # so-called 'partial' requests get just the grid data + if self.request.params.get('partial'): + context = grid.get_vue_context() + if grid.paginated and grid.paginate_on_backend: + context['pager_stats'] = grid.get_vue_pager_stats() + return self.json_response(context) + + context['rows_grid'] = grid + return self.render_to_response('view', context) ############################## @@ -1907,8 +1974,8 @@ class MasterView(View): This is called by :meth:`make_model_grid()`. - There is no default logic here; subclass should override as - needed. The ``grid`` param will already be "complete" and + There is minimal default logic here; subclass should override + as needed. The ``grid`` param will already be "complete" and ready to use as-is, but this method can further modify it based on request details etc. """ @@ -2241,6 +2308,182 @@ class MasterView(View): session = session or self.Session() session.add(obj) + ############################## + # row methods + ############################## + + def get_rows_title(self): + """ + Returns the display title for model **rows** grid, if + applicable/desired. Only relevant if :attr:`has_rows` is + true. + + There is no default here, but subclass may override by + assigning :attr:`rows_title`. + """ + if hasattr(self, 'rows_title'): + return self.rows_title + + def make_row_model_grid(self, obj, **kwargs): + """ + Create and return a grid for a record's **rows** data, for use + in :meth:`view()`. Only applicable if :attr:`has_rows` is + true. + + :param obj: Current model instance for which rows data is + being displayed. + + :returns: :class:`~wuttaweb.grids.base.Grid` instance for the + rows data. + + See also related methods, which are called by this one: + + * :meth:`get_row_grid_key()` + * :meth:`get_row_grid_columns()` + * :meth:`get_row_grid_data()` + * :meth:`configure_row_grid()` + """ + if 'key' not in kwargs: + kwargs['key'] = self.get_row_grid_key() + + if 'model_class' not in kwargs: + model_class = self.get_row_model_class() + if model_class: + kwargs['model_class'] = model_class + + if 'columns' not in kwargs: + kwargs['columns'] = self.get_row_grid_columns() + + if 'data' not in kwargs: + kwargs['data'] = self.get_row_grid_data(obj) + + kwargs.setdefault('filterable', self.rows_filterable) + kwargs.setdefault('filter_defaults', self.rows_filter_defaults) + kwargs.setdefault('sortable', self.rows_sortable) + kwargs.setdefault('sort_multiple', not self.request.use_oruga) + kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend) + kwargs.setdefault('sort_defaults', self.rows_sort_defaults) + kwargs.setdefault('paginated', self.rows_paginated) + kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend) + + grid = self.make_grid(**kwargs) + self.configure_row_grid(grid) + grid.load_settings() + return grid + + def get_row_grid_key(self): + """ + Returns the (presumably) unique key to be used for the + **rows** grid in :meth:`view()`. Only relevant if + :attr:`has_rows` is true. + + This is called from :meth:`make_row_model_grid()`; in the + resulting grid, this becomes + :attr:`~wuttaweb.grids.base.Grid.key`. + + Whereas you can define :attr:`grid_key` for the main grid, the + row grid key is always generated dynamically. This + incorporates the current record key (whose rows are in the + grid) so that the rows grid for each record is unique. + """ + parts = [self.get_grid_key()] + for key in self.get_model_key(): + parts.append(str(self.request.matchdict[key])) + return '.'.join(parts) + + def get_row_grid_columns(self): + """ + Returns the default list of column names for the **rows** + grid, for use in :meth:`view()`. Only relevant if + :attr:`has_rows` is true. + + This is called by :meth:`make_row_model_grid()`; in the + resulting grid, this becomes + :attr:`~wuttaweb.grids.base.Grid.columns`. + + This method may return ``None``, in which case the grid may + (try to) generate its own default list. + + Subclass may define :attr:`row_grid_columns` for simple cases, + or can override this method if needed. + + Also note that :meth:`configure_row_grid()` may be used to + further modify the final column set, regardless of what this + method returns. So a common pattern is to declare all + "supported" columns by setting :attr:`row_grid_columns` but + then optionally remove or replace some of those within + :meth:`configure_row_grid()`. + """ + if hasattr(self, 'row_grid_columns'): + return self.row_grid_columns + + def get_row_grid_data(self, obj): + """ + Returns the data for the **rows** grid, for use in + :meth:`view()`. Only relevant if :attr:`has_rows` is true. + + This is called by :meth:`make_row_model_grid()`; in the + resulting grid, this becomes + :attr:`~wuttaweb.grids.base.Grid.data`. + + Default logic not implemented; subclass must define this. + """ + raise NotImplementedError + + def configure_row_grid(self, grid): + """ + Configure the **rows** grid for use in :meth:`view()`. Only + relevant if :attr:`has_rows` is true. + + This is called by :meth:`make_row_model_grid()`. + + There is minimal default logic here; subclass should override + as needed. The ``grid`` param will already be "complete" and + ready to use as-is, but this method can further modify it + based on request details etc. + """ + grid.remove('uuid') + self.set_row_labels(grid) + + def set_row_labels(self, obj): + """ + Set label overrides on a **row** form or grid, based on what + is defined by the view class and its parent class(es). + + This is called automatically from + :meth:`configure_row_grid()` and + :meth:`configure_row_form()`. + + This calls :meth:`collect_row_labels()` to find everything, + then it assigns the labels using one of (based on ``obj`` + type): + + * :func:`wuttaweb.forms.base.Form.set_label()` + * :func:`wuttaweb.grids.base.Grid.set_label()` + + :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a + :class:`~wuttaweb.forms.base.Form` instance. + """ + labels = self.collect_row_labels() + for key, label in labels.items(): + obj.set_label(key, label) + + def collect_row_labels(self): + """ + Collect all **row** labels defined within the view class + hierarchy. + + This is called by :meth:`set_row_labels()`. + + :returns: Dict of all labels found. + """ + labels = {} + hierarchy = self.get_class_hierarchy() + for cls in hierarchy: + if hasattr(cls, 'row_labels'): + labels.update(cls.row_labels) + return labels + ############################## # class methods ############################## @@ -2526,6 +2769,18 @@ class MasterView(View): return cls.get_model_title_plural() + @classmethod + def get_row_model_class(cls): + """ + Returns the **row** model class for the view, if defined. + Only relevant if :attr:`has_rows` is true. + + There is no default here, but a subclass may override by + assigning :attr:`row_model_class`. + """ + if hasattr(cls, 'row_model_class'): + return cls.row_model_class + ############################## # configuration ############################## diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index 26ea706..fb2f46b 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -3,6 +3,7 @@ import datetime from unittest.mock import patch, MagicMock +from sqlalchemy import orm from pyramid.httpexceptions import HTTPFound from wuttjamaican.db import model @@ -19,12 +20,23 @@ class MockBatchRow(model.BatchRowMixin, model.Base): __tablename__ = 'testing_batch_mock_row' __batch_class__ = MockBatch +MockBatch.__row_class__ = MockBatchRow + class MockBatchHandler(BatchHandler): model_class = MockBatch class TestBatchMasterView(WebTestCase): + def setUp(self): + self.setup_web() + + # nb. create MockBatch, MockBatchRow + model.Base.metadata.create_all(bind=self.session.bind) + + def make_view(self): + return mod.BatchMasterView(self.request) + def test_get_batch_handler(self): self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request) @@ -200,3 +212,78 @@ class TestBatchMasterView(WebTestCase): self.session.commit() # nb. should give up waiting after 1 second self.assertRaises(RuntimeError, view.populate_thread, batch.uuid) + + def test_get_row_model_class(self): + handler = MockBatchHandler(self.config) + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + view = self.make_view() + + self.assertRaises(AttributeError, view.get_row_model_class) + + # row class determined from batch class + with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True): + cls = view.get_row_model_class() + self.assertIs(cls, MockBatchRow) + + self.assertRaises(AttributeError, view.get_row_model_class) + + # view may specify row class + with patch.object(mod.BatchMasterView, 'row_model_class', new=MockBatchRow, create=True): + cls = view.get_row_model_class() + self.assertIs(cls, MockBatchRow) + + def test_get_row_grid_data(self): + handler = MockBatchHandler(self.config) + model = self.app.model + + user = model.User(username='barney') + self.session.add(user) + + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + row = handler.make_row() + handler.add_row(batch, row) + self.session.flush() + + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + + view = self.make_view() + self.assertRaises(AttributeError, view.get_row_grid_data, batch) + + Session = MagicMock(return_value=self.session) + Session.query.side_effect = lambda m: self.session.query(m) + with patch.multiple(mod.BatchMasterView, create=True, + Session=Session, + model_class=MockBatch): + + view = self.make_view() + data = view.get_row_grid_data(batch) + self.assertIsInstance(data, orm.Query) + self.assertEqual(data.count(), 1) + + def test_configure_row_grid(self): + handler = MockBatchHandler(self.config) + model = self.app.model + + user = model.User(username='barney') + self.session.add(user) + + batch = handler.make_batch(self.session, created_by=user) + self.session.add(batch) + row = handler.make_row() + handler.add_row(batch, row) + self.session.flush() + + with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler): + + Session = MagicMock(return_value=self.session) + Session.query.side_effect = lambda m: self.session.query(m) + with patch.multiple(mod.BatchMasterView, create=True, + Session=Session, + model_class=MockBatch): + + with patch.object(self.request, 'matchdict', new={'uuid': batch.uuid}): + view = self.make_view() + grid = view.make_row_model_grid(batch) + self.assertIn('sequence', grid.labels) + self.assertEqual(grid.labels['sequence'], "Seq.") diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 8e451ee..06d8ed1 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -334,6 +334,16 @@ class TestMasterView(WebTestCase): model_class=MyModel): self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs") + def test_get_row_model_class(self): + model = self.app.model + + # no default + self.assertIsNone(mod.MasterView.get_row_model_class()) + + # class may specify + with patch.object(mod.MasterView, 'row_model_class', create=True, new=model.User): + self.assertIs(mod.MasterView.get_row_model_class(), model.User) + ############################## # support methods ############################## @@ -1017,6 +1027,53 @@ class TestMasterView(WebTestCase): with patch.object(view, 'get_instance', return_value=setting): response = view.view() + def test_view_with_rows(self): + self.pyramid_config.include('wuttaweb.views.common') + self.pyramid_config.include('wuttaweb.views.auth') + self.pyramid_config.add_route('people', '/people/') + model = self.app.model + person = model.Person(full_name="Whitney Houston") + self.session.add(person) + user = model.User(username='whitney', person=person) + self.session.add(user) + self.session.commit() + + get_row_grid_data = MagicMock() + with patch.multiple(mod.MasterView, create=True, + Session=MagicMock(return_value=self.session), + model_class=model.Person, + route_prefix='people', + has_rows=True, + row_model_class=model.User, + get_row_grid_data=get_row_grid_data): + with patch.object(self.request, 'matchdict', new={'uuid': person.uuid}): + view = self.make_view() + + # just for coverage + get_row_grid_data.return_value = [] + response = view.view() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # now with data... + get_row_grid_data.return_value = [user] + response = view.view() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # then once more as 'partial' - aka. data only + with patch.dict(self.request.GET, {'partial': 1}): + response = view.view() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') + + # redirects when view is reset + with patch.dict(self.request.GET, {'reset-view': '1', 'hash': 'foo'}): + # nb. mock current route + with patch.object(self.request, 'current_route_url'): + response = view.view() + self.assertEqual(response.status_code, 302) + def test_edit(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') @@ -1501,3 +1558,103 @@ class TestMasterView(WebTestCase): # should now have 0 settings count = self.session.query(model.Setting).count() self.assertEqual(count, 0) + + ############################## + # row methods + ############################## + + def test_collect_row_labels(self): + + # default labels + view = self.make_view() + labels = view.collect_row_labels() + self.assertEqual(labels, {}) + + # labels come from all classes; subclass wins + with patch.object(View, 'row_labels', create=True, new={'foo': "Foo", 'bar': "Bar"}): + with patch.object(mod.MasterView, 'row_labels', create=True, new={'foo': "FOO FIGHTERS"}): + view = self.make_view() + labels = view.collect_row_labels() + self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"}) + + def test_set_row_labels(self): + model = self.app.model + person = model.Person(full_name="Fred Flintstone") + self.session.add(person) + + with patch.multiple(mod.MasterView, create=True, + model_class=model.Person, + has_rows=True, + row_model_class=model.User): + + # no labels by default + view = self.make_view() + grid = view.make_row_model_grid(person, key='person.users', data=[]) + view.set_row_labels(grid) + self.assertEqual(grid.labels, {}) + + # labels come from all classes; subclass wins + with patch.object(View, 'row_labels', create=True, new={'username': "USERNAME"}): + with patch.object(mod.MasterView, 'row_labels', create=True, new={'username': "UserName"}): + view = self.make_view() + grid = view.make_row_model_grid(person, key='person.users', data=[]) + view.set_row_labels(grid) + self.assertEqual(grid.labels, {'username': "UserName"}) + + def test_get_row_grid_data(self): + model = self.app.model + person = model.Person(full_name="Fred Flintstone") + self.session.add(person) + view = self.make_view() + self.assertRaises(NotImplementedError, view.get_row_grid_data, person) + + def test_get_row_grid_columns(self): + + # no default + view = self.make_view() + self.assertIsNone(view.get_row_grid_columns()) + + # class may specify + with patch.object(view, 'row_grid_columns', create=True, new=['foo', 'bar']): + self.assertEqual(view.get_row_grid_columns(), ['foo', 'bar']) + + def test_get_row_grid_key(self): + view = self.make_view() + with patch.multiple(mod.MasterView, create=True, + model_key='id', + grid_key='widgets'): + + self.request.matchdict = {'id': 42} + self.assertEqual(view.get_row_grid_key(), 'widgets.42') + + def test_make_row_model_grid(self): + model = self.app.model + person = model.Person(full_name="Barney Rubble") + self.session.add(person) + self.session.commit() + + self.request.matchdict = {'uuid': person.uuid} + with patch.multiple(mod.MasterView, create=True, + model_class=model.Person): + view = self.make_view() + + # specify data + grid = view.make_row_model_grid(person, data=[]) + self.assertIsNone(grid.model_class) + self.assertEqual(grid.data, []) + + # fetch data + with patch.object(view, 'get_row_grid_data', return_value=[]): + grid = view.make_row_model_grid(person) + self.assertIsNone(grid.model_class) + self.assertEqual(grid.data, []) + + def test_get_rows_title(self): + view = self.make_view() + + # no default + self.assertIsNone(view.get_rows_title()) + + # class may specify + with patch.object(view, 'rows_title', create=True, new="Mock Rows"): + self.assertEqual(view.get_rows_title(), "Mock Rows") From dd1fd8c0ce1a050c63a035c310dc67bcf6afb5f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Dec 2024 20:42:57 -0600 Subject: [PATCH 3/5] feat: add basic support for batch execution no execution options yet, and no progress indicator also basic delete support, invoking handler --- pyproject.toml | 1 + src/wuttaweb/templates/base.mako | 4 + src/wuttaweb/templates/batch/view.mako | 124 +++++++++++++++++++ src/wuttaweb/templates/form.mako | 21 ++++ src/wuttaweb/templates/master/view.mako | 32 +++-- src/wuttaweb/templates/page.mako | 6 +- src/wuttaweb/templates/wutta-components.mako | 26 ++++ src/wuttaweb/views/batch.py | 115 +++++++++++++++++ tests/views/test_batch.py | 86 ++++++++++++- 9 files changed, 404 insertions(+), 11 deletions(-) create mode 100644 src/wuttaweb/templates/batch/view.mako diff --git a/pyproject.toml b/pyproject.toml index 0a0435c..3a7c5cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", "humanize", + "markdown", "paginate", "paginate_sqlalchemy", "pyramid>=2", diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index a33d3ca..df05cbb 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -201,6 +201,10 @@ width: 100%; } + .tool-panels-wrapper { + padding: 1rem; + } + diff --git a/src/wuttaweb/templates/batch/view.mako b/src/wuttaweb/templates/batch/view.mako new file mode 100644 index 0000000..569af5b --- /dev/null +++ b/src/wuttaweb/templates/batch/view.mako @@ -0,0 +1,124 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + +<%def name="tool_panels()"> + ${parent.tool_panels()} + ${self.tool_panel_execution()} + + +<%def name="tool_panel_execution()"> + + % if batch.executed: + +

+ Batch was executed
+ ${app.render_time_ago(batch.executed)} + by ${batch.executed_by}. +

+
+ % elif why_not_execute: + +

+ Batch cannot be executed: +

+

+ ${why_not_execute} +

+
+ % else: + % if master.has_perm('execute'): + +

+ Batch can be executed. +

+ + Execute Batch + + + + + +
+ + % else: + +

+ Batch may be executed,
+ but you do not have permission. +

+
+ % endif + % endif +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if not batch.executed and not why_not_execute and master.has_perm('execute'): + + % endif + diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako index de7209a..1a4fe2d 100644 --- a/src/wuttaweb/templates/form.mako +++ b/src/wuttaweb/templates/form.mako @@ -1,6 +1,19 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> +<%def name="page_layout()"> +
+ + ## main form +
+ ${self.page_content()} +
+ + ## tool panels + ${self.tool_panels_wrapper()} +
+ + <%def name="page_content()"> % if form is not Undefined:
@@ -9,6 +22,14 @@ % endif +<%def name="tool_panels_wrapper()"> +
+ ${self.tool_panels()} +
+ + +<%def name="tool_panels()"> + <%def name="render_vue_template_form()"> % if form is not Undefined: ${form.render_vue_template()} diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako index c7021eb..b4db013 100644 --- a/src/wuttaweb/templates/master/view.mako +++ b/src/wuttaweb/templates/master/view.mako @@ -5,18 +5,32 @@ <%def name="content_title()">${instance_title} -<%def name="page_content()"> +<%def name="page_layout()"> - ## render main form - ${parent.page_content()} - - ## render row grid % if master.has_rows: -
-

${master.get_rows_title() or ''}

- ${rows_grid.render_vue_tag()} - % endif +
+
+ ## main form +
+ ${self.page_content()} +
+ + ## tool panels + ${self.tool_panels_wrapper()} + +
+ + ## rows grid +
+

${master.get_rows_title() or ''}

+ ${rows_grid.render_vue_tag()} +
+ + % else: + ## no rows, just main form + tool panels + ${parent.page_layout()} + % endif <%def name="render_vue_templates()"> diff --git a/src/wuttaweb/templates/page.mako b/src/wuttaweb/templates/page.mako index 218e9f4..c23ce90 100644 --- a/src/wuttaweb/templates/page.mako +++ b/src/wuttaweb/templates/page.mako @@ -1,6 +1,10 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> +<%def name="page_layout()"> + ${self.page_content()} + + <%def name="page_content()"> <%def name="render_vue_templates()"> @@ -12,7 +16,7 @@ <%def name="render_vue_template_this_page()"> diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index 6030840..5664933 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -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 name="make_wutta_request_mixin()"> @@ -477,3 +478,28 @@ + +<%def name="make_wutta_tool_panel_component()"> + + + diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py index 1383ec9..1645455 100644 --- a/src/wuttaweb/views/batch.py +++ b/src/wuttaweb/views/batch.py @@ -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}") diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index fb2f46b..a3a34fe 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -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) From 30671fcd785f5cf8fc27d7b0628052f204445e73 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 14 Dec 2024 23:56:36 -0600 Subject: [PATCH 4/5] fix: add handling for decimal values and lists, in `make_json_safe()` --- src/wuttaweb/util.py | 20 ++++++++++++++++---- tests/test_util.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index c0069d4..0697f03 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -24,6 +24,7 @@ Web Utilities """ +import decimal import importlib import json import logging @@ -525,17 +526,28 @@ def make_json_safe(value, key=None, warn=True): if value is colander.null: return None - # recursively convert dict - if isinstance(value, dict): + elif isinstance(value, dict): + # recursively convert dict parent = dict(value) for key, value in parent.items(): parent[key] = make_json_safe(value, key=key, warn=warn) value = parent - # convert UUID to str - if isinstance(value, _uuid.UUID): + elif isinstance(value, list): + # recursively convert list + parent = list(value) + for i, value in enumerate(parent): + parent[i] = make_json_safe(value, key=key, warn=warn) + value = parent + + elif isinstance(value, _uuid.UUID): + # convert UUID to str value = value.hex + elif isinstance(value, decimal.Decimal): + # convert decimal to float + value = float(value) + # ensure JSON-compatibility, warn if problems try: json.dumps(value) diff --git a/tests/test_util.py b/tests/test_util.py index 21de3a4..6946d65 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,6 @@ # -*- coding: utf-8; -*- +import decimal import json import uuid as _uuid from unittest import TestCase @@ -570,6 +571,12 @@ class TestMakeJsonSafe(TestCase): value = mod.make_json_safe(uuid) self.assertEqual(value, uuid.hex) + def test_decimal(self): + value = decimal.Decimal('42.42') + self.assertNotEqual(value, 42.42) + result = mod.make_json_safe(value) + self.assertEqual(result, 42.42) + def test_dict(self): model = self.app.model person = model.Person(full_name="Betty Boop") @@ -585,3 +592,21 @@ class TestMakeJsonSafe(TestCase): 'foo': 'bar', 'person': "Betty Boop", }) + + def test_list(self): + model = self.app.model + person = model.Person(full_name="Betty Boop") + + data = [ + 'foo', + 'bar', + person, + ] + + self.assertRaises(TypeError, json.dumps, data) + value = mod.make_json_safe(data) + self.assertEqual(value, [ + 'foo', + 'bar', + "Betty Boop", + ]) From 180acc509f31239ccb9046c5304bf1f392aa3b5f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 15 Dec 2024 00:48:07 -0600 Subject: [PATCH 5/5] =?UTF-8?q?bump:=20version=200.16.2=20=E2=86=92=200.17?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 +++++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63497e1..4500527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.17.0 (2024-12-15) + +### Feat + +- add basic support for batch execution +- add basic support for rows grid for master, batch views +- add basic master view class for batches + +### Fix + +- add handling for decimal values and lists, in `make_json_safe()` +- fix behavior when editing Roles for a User +- add basic views for raw Permissions +- improve support for date, datetime fields in grids, forms +- add way to set field widgets using pseudo-type +- add support for date, datetime form fields +- make dropdown widgets as wide as other text fields in main form +- add fallback instance title +- display "global" errors at top of form, if present +- add `make_form()` and `make_grid()` methods on web handler +- correct "empty option" behavior for `ObjectRef` schema type +- use fanstatic to serve built-in images by default + ## v0.16.2 (2024-12-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3a7c5cc..5aed35f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.16.2" +version = "0.17.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -43,7 +43,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.17.1", + "WuttJamaican[db]>=0.18.0", "zope.sqlalchemy>=1.5", ]