Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/batch.py: 100%
163 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 17:06 -0600
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-06 17:06 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base logic for Batch Master views
25"""
27import logging
28import threading
29import time
31import markdown
32from sqlalchemy import orm
34from wuttaweb.views import MasterView
35from wuttaweb.forms.schema import UserRef
36from wuttaweb.forms.widgets import BatchIdWidget
39log = logging.getLogger(__name__)
42class BatchMasterView(MasterView):
43 """
44 Base class for all "batch master" views.
46 .. attribute:: batch_handler
48 Reference to the :term:`batch handler` for use with the view.
50 This is set when the view is first created, using return value
51 from :meth:`get_batch_handler()`.
52 """
54 labels = {
55 'id': "Batch ID",
56 'status_code': "Status",
57 }
59 sort_defaults = ('id', 'desc')
61 has_rows = True
62 rows_title = "Batch Rows"
63 rows_sort_defaults = 'sequence'
65 row_labels = {
66 'status_code': "Status",
67 }
69 def __init__(self, request, context=None):
70 super().__init__(request, context=context)
71 self.batch_handler = self.get_batch_handler()
73 def get_batch_handler(self):
74 """
75 Must return the :term:`batch handler` for use with this view.
77 There is no default logic; subclass must override.
78 """
79 raise NotImplementedError
81 def get_fallback_templates(self, template):
82 """
83 We override the default logic here, to prefer "batch"
84 templates over the "master" templates.
86 So for instance the "view batch" page will by default use the
87 ``/batch/view.mako`` template - which does inherit from
88 ``/master/view.mako`` but adds extra features specific to
89 batches.
90 """
91 templates = super().get_fallback_templates(template)
92 templates.insert(0, f'/batch/{template}.mako')
93 return templates
95 def render_to_response(self, template, context):
96 """
97 We override the default logic here, to inject batch-related
98 context for the
99 :meth:`~wuttaweb.views.master.MasterView.view()` template
100 specifically. These values are used in the template file,
101 ``/batch/view.mako``.
103 * ``batch`` - reference to the current :term:`batch`
104 * ``batch_handler`` reference to :attr:`batch_handler`
105 * ``why_not_execute`` - text of reason (if any) not to execute batch
106 * ``execution_described`` - HTML (rendered from markdown) describing batch execution
107 """
108 if template == 'view':
109 batch = context['instance']
110 context['batch'] = batch
111 context['batch_handler'] = self.batch_handler
112 context['why_not_execute'] = self.batch_handler.why_not_execute(batch)
114 description = (self.batch_handler.describe_execution(batch)
115 or "Handler does not say! Your guess is as good as mine.")
116 context['execution_described'] = markdown.markdown(
117 description, extensions=['fenced_code', 'codehilite'])
119 return super().render_to_response(template, context)
121 def configure_grid(self, g):
122 """ """
123 super().configure_grid(g)
124 model = self.app.model
126 # created_by
127 CreatedBy = orm.aliased(model.User)
128 g.set_joiner('created_by',
129 lambda q: q.join(CreatedBy,
130 CreatedBy.uuid == self.model_class.created_by_uuid))
131 g.set_sorter('created_by', CreatedBy.username)
132 # g.set_filter('created_by', CreatedBy.username, label="Created By Username")
134 # id
135 g.set_renderer('id', self.render_batch_id)
136 g.set_link('id')
138 # description
139 g.set_link('description')
141 def render_batch_id(self, batch, key, value):
142 """ """
143 if value:
144 batch_id = int(value)
145 return f'{batch_id:08d}'
147 def get_instance_title(self, batch):
148 """ """
149 if batch.description:
150 return f"{batch.id_str} {batch.description}"
151 return batch.id_str
153 def configure_form(self, f):
154 """ """
155 super().configure_form(f)
156 batch = f.model_instance
158 # id
159 if self.creating:
160 f.remove('id')
161 else:
162 f.set_readonly('id')
163 f.set_widget('id', BatchIdWidget())
165 # notes
166 f.set_widget('notes', 'notes')
168 # rows
169 f.remove('rows')
170 if self.creating:
171 f.remove('row_count')
172 else:
173 f.set_readonly('row_count')
175 # status
176 f.remove('status_text')
177 if self.creating:
178 f.remove('status_code')
179 else:
180 f.set_readonly('status_code')
182 # created
183 if self.creating:
184 f.remove('created')
185 else:
186 f.set_readonly('created')
188 # created_by
189 f.remove('created_by_uuid')
190 if self.creating:
191 f.remove('created_by')
192 else:
193 f.set_node('created_by', UserRef(self.request))
194 f.set_readonly('created_by')
196 # executed
197 if self.creating or not batch.executed:
198 f.remove('executed')
199 else:
200 f.set_readonly('executed')
202 # executed_by
203 f.remove('executed_by_uuid')
204 if self.creating or not batch.executed:
205 f.remove('executed_by')
206 else:
207 f.set_node('executed_by', UserRef(self.request))
208 f.set_readonly('executed_by')
210 def objectify(self, form, **kwargs):
211 """
212 We override the default logic here, to invoke
213 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
214 on the batch handler - when creating. Parent/default logic is
215 used when updating.
217 :param \**kwargs: Additional kwargs will be passed as-is to
218 the ``make_batch()`` call.
219 """
220 if self.creating:
222 # first get the "normal" objectified batch. this will have
223 # all attributes set correctly per the form data, but will
224 # not yet belong to the db session. we ultimately discard it.
225 schema = form.get_schema()
226 batch = schema.objectify(form.validated, context=form.model_instance)
228 # then we collect attributes from the new batch
229 kw = dict([(key, getattr(batch, key))
230 for key in form.validated
231 if hasattr(batch, key)])
233 # and set attribute for user creating the batch
234 kw['created_by'] = self.request.user
236 # plus caller can override anything
237 kw.update(kwargs)
239 # finally let batch handler make the "real" batch
240 return self.batch_handler.make_batch(self.Session(), **kw)
242 # when not creating, normal logic is fine
243 return super().objectify(form)
245 def redirect_after_create(self, batch):
246 """
247 If the new batch requires initial population, we launch a
248 thread for that and show the "progress" page.
250 Otherwise this will do the normal thing of redirecting to the
251 "view" page for the new batch.
252 """
253 # just view batch if should not populate
254 if not self.batch_handler.should_populate(batch):
255 return self.redirect(self.get_action_url('view', batch))
257 # setup thread to populate batch
258 route_prefix = self.get_route_prefix()
259 key = f'{route_prefix}.populate'
260 progress = self.make_progress(key, success_url=self.get_action_url('view', batch))
261 thread = threading.Thread(target=self.populate_thread,
262 args=(batch.uuid,),
263 kwargs=dict(progress=progress))
265 # start thread and show progress page
266 thread.start()
267 return self.render_progress(progress)
269 def delete_instance(self, batch):
270 """
271 Delete the given batch instance.
273 This calls
274 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()`
275 on the :attr:`batch_handler`.
276 """
277 self.batch_handler.do_delete(batch, self.request.user)
279 ##############################
280 # populate methods
281 ##############################
283 def populate_thread(self, batch_uuid, progress=None):
284 """
285 Thread target for populating new object with progress indicator.
287 When a new batch is created, and the batch handler says it
288 should also be populated, then this thread is launched to do
289 so outside of the main request/response cycle. Progress bar
290 is then shown to the user until it completes.
292 This method mostly just calls
293 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
294 on the :term:`batch handler`.
295 """
296 # nb. must use our own session in separate thread
297 session = self.app.make_session()
299 # nb. main web request which created the batch, must complete
300 # before that session is committed. until that happens we
301 # will not be able to see the new batch. hence this loop,
302 # where we wait for the batch to appear.
303 batch = None
304 tries = 0
305 while not batch:
306 batch = session.get(self.model_class, batch_uuid)
307 tries += 1
308 if tries > 10:
309 raise RuntimeError("can't find the batch")
310 time.sleep(0.1)
312 try:
313 # populate the batch
314 self.batch_handler.do_populate(batch, progress=progress)
315 session.flush()
317 except Exception as error:
318 session.rollback()
319 log.warning("failed to populate %s: %s",
320 self.get_model_title(), batch,
321 exc_info=True)
322 if progress:
323 progress.handle_error(error)
325 else:
326 session.commit()
327 if progress:
328 progress.handle_success()
330 finally:
331 session.close()
333 ##############################
334 # execute methods
335 ##############################
337 def execute(self):
338 """
339 View to execute the current :term:`batch`.
341 Eventually this should show a progress indicator etc., but for
342 now it simply calls
343 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
344 on the :attr:`batch_handler` and waits for it to complete,
345 then redirects user back to the "view batch" page.
346 """
347 self.executing = True
348 batch = self.get_instance()
350 try:
351 self.batch_handler.do_execute(batch, self.request.user)
352 except Exception as error:
353 log.warning("failed to execute batch: %s", batch, exc_info=True)
354 self.request.session.flash(f"Execution failed!: {error}", 'error')
356 return self.redirect(self.get_action_url('view', batch))
358 ##############################
359 # row methods
360 ##############################
362 @classmethod
363 def get_row_model_class(cls):
364 """ """
365 if hasattr(cls, 'row_model_class'):
366 return cls.row_model_class
368 Batch = cls.get_model_class()
369 return Batch.__row_class__
371 def get_row_grid_data(self, batch):
372 """
373 Returns the base query for the batch
374 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
375 data.
376 """
377 BatchRow = self.get_row_model_class()
378 query = self.Session.query(BatchRow)\
379 .filter(BatchRow.batch == batch)
380 return query
382 def configure_row_grid(self, g):
383 """ """
384 super().configure_row_grid(g)
386 g.set_label('sequence', "Seq.", column_only=True)
388 g.set_renderer('status_code', self.render_row_status)
390 def render_row_status(self, row, key, value):
391 """ """
392 return row.STATUS.get(value, value)
394 ##############################
395 # configuration
396 ##############################
398 @classmethod
399 def defaults(cls, config):
400 """ """
401 cls._defaults(config)
402 cls._batch_defaults(config)
404 @classmethod
405 def _batch_defaults(cls, config):
406 route_prefix = cls.get_route_prefix()
407 permission_prefix = cls.get_permission_prefix()
408 model_title = cls.get_model_title()
409 instance_url_prefix = cls.get_instance_url_prefix()
411 # execute
412 config.add_route(f'{route_prefix}.execute',
413 f'{instance_url_prefix}/execute',
414 request_method='POST')
415 config.add_view(cls, attr='execute',
416 route_name=f'{route_prefix}.execute',
417 permission=f'{permission_prefix}.execute')
418 config.add_wutta_permission(permission_prefix,
419 f'{permission_prefix}.execute',
420 f"Execute {model_title}")