Source code for wuttaweb.views.batch

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

import markdown
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__)


[docs] 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 = { 'id': "Batch ID", 'status_code': "Status", } sort_defaults = ('id', 'desc') has_rows = True rows_title = "Batch Rows" rows_sort_defaults = 'sequence' row_labels = { 'status_code': "Status", } def __init__(self, request, context=None): super().__init__(request, context=context) self.batch_handler = self.get_batch_handler()
[docs] 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
[docs] 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
[docs] 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) 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')
[docs] def objectify(self, form, **kwargs): """ 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. :param \**kwargs: Additional kwargs will be passed as-is to the ``make_batch()`` call. """ 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 kw = dict([(key, getattr(batch, key)) for key in form.validated if hasattr(batch, key)]) # and set attribute for user creating the batch kw['created_by'] = self.request.user # plus caller can override anything kw.update(kwargs) # finally let batch handler make the "real" batch return self.batch_handler.make_batch(self.Session(), **kw) # when not creating, normal logic is fine return super().objectify(form)
[docs] 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)
[docs] 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 ##############################
[docs] 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()
############################## # execute methods ##############################
[docs] 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 ############################## @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__
[docs] 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) g.set_renderer('status_code', self.render_row_status) def render_row_status(self, row, key, value): """ """ return row.STATUS.get(value, value) ############################## # 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}")