Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / batch.py: 100%
190 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-02 19:45 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-02 19:45 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 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, WuttaDictEnum
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 executable = True
56 labels = {
57 "id": "Batch ID",
58 "status_code": "Status",
59 }
61 sort_defaults = ("id", "desc")
63 has_rows = True
64 row_model_title = "Batch Row"
65 rows_sort_defaults = "sequence"
67 row_labels = {
68 "status_code": "Status",
69 }
71 def __init__(self, request, context=None):
72 super().__init__(request, context=context)
73 self.batch_handler = self.get_batch_handler()
75 def get_batch_handler(self):
76 """
77 Must return the :term:`batch handler` for use with this view.
79 There is no default logic; subclass must override.
80 """
81 raise NotImplementedError
83 def get_fallback_templates(self, template):
84 """
85 We override the default logic here, to prefer "batch"
86 templates over the "master" templates.
88 So for instance the "view batch" page will by default use the
89 ``/batch/view.mako`` template - which does inherit from
90 ``/master/view.mako`` but adds extra features specific to
91 batches.
92 """
93 templates = super().get_fallback_templates(template)
94 templates.insert(0, f"/batch/{template}.mako")
95 return templates
97 def render_to_response(self, template, context):
98 """
99 We override the default logic here, to inject batch-related
100 context for the
101 :meth:`~wuttaweb.views.master.MasterView.view()` template
102 specifically. These values are used in the template file,
103 ``/batch/view.mako``.
105 * ``batch`` - reference to the current :term:`batch`
106 * ``batch_handler`` reference to :attr:`batch_handler`
107 * ``why_not_execute`` - text of reason (if any) not to execute batch
108 * ``execution_described`` - HTML (rendered from markdown) describing batch execution
109 """
110 if template == "view":
111 batch = context["instance"]
112 context["batch"] = batch
113 context["batch_handler"] = self.batch_handler
114 context["why_not_execute"] = self.batch_handler.why_not_execute(batch)
116 description = (
117 self.batch_handler.describe_execution(batch)
118 or "Handler does not say! Your guess is as good as mine."
119 )
120 context["execution_described"] = markdown.markdown(
121 description, extensions=["fenced_code", "codehilite"]
122 )
124 return super().render_to_response(template, context)
126 def configure_grid(self, grid): # pylint: disable=empty-docstring
127 """ """
128 g = grid
129 super().configure_grid(g)
130 model = self.app.model
132 # created_by
133 CreatedBy = orm.aliased(model.User) # pylint: disable=invalid-name
134 g.set_joiner(
135 "created_by",
136 lambda q: q.join(
137 CreatedBy, CreatedBy.uuid == self.model_class.created_by_uuid
138 ),
139 )
140 g.set_sorter("created_by", CreatedBy.username)
141 # g.set_filter('created_by', CreatedBy.username, label="Created By Username")
143 # id
144 g.set_renderer("id", self.render_batch_id)
145 g.set_link("id")
147 # description
148 g.set_link("description")
150 # status_code
151 g.set_enum("status_code", self.model_class.STATUS)
153 def render_batch_id( # pylint: disable=empty-docstring,unused-argument
154 self, batch, key, value
155 ):
156 """ """
157 if value:
158 batch_id = int(value)
159 return f"{batch_id:08d}"
160 return None
162 def get_instance_title(self, instance): # pylint: disable=empty-docstring
163 """ """
164 batch = instance
165 if batch.description:
166 return f"{batch.id_str} {batch.description}"
167 return batch.id_str
169 def configure_form(self, form): # pylint: disable=too-many-branches,empty-docstring
170 """ """
171 super().configure_form(form)
172 f = form
173 batch = f.model_instance
175 # id
176 if self.creating:
177 f.remove("id")
178 else:
179 f.set_readonly("id")
180 f.set_widget("id", BatchIdWidget())
182 # notes
183 f.set_widget("notes", "notes")
185 # rows
186 f.remove("rows")
187 if self.creating:
188 f.remove("row_count")
189 else:
190 f.set_readonly("row_count")
192 # status
193 f.remove("status_text")
194 if self.creating:
195 f.remove("status_code")
196 else:
197 f.set_node("status_code", WuttaDictEnum(self.request, batch.STATUS))
198 f.set_readonly("status_code")
200 # created
201 if self.creating:
202 f.remove("created")
203 else:
204 f.set_readonly("created")
206 # created_by
207 f.remove("created_by_uuid")
208 if self.creating:
209 f.remove("created_by")
210 else:
211 f.set_node("created_by", UserRef(self.request))
212 f.set_readonly("created_by")
214 # executed
215 if self.creating or not batch.executed:
216 f.remove("executed")
217 else:
218 f.set_readonly("executed")
220 # executed_by
221 f.remove("executed_by_uuid")
222 if self.creating or not batch.executed:
223 f.remove("executed_by")
224 else:
225 f.set_node("executed_by", UserRef(self.request))
226 f.set_readonly("executed_by")
228 def is_editable(self, batch): # pylint: disable=arguments-renamed
229 """
230 This overrides the parent method
231 :meth:`~wuttaweb.views.master.MasterView.is_editable()` to
232 return ``False`` if the batch has already been executed.
233 """
234 return not batch.executed
236 def objectify(self, form, **kwargs):
237 """
238 We override the default logic here, to invoke
239 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
240 on the batch handler - when creating. Parent/default logic is
241 used when updating.
243 :param \\**kwargs: Additional kwargs will be passed as-is to
244 the ``make_batch()`` call.
245 """
246 model = self.app.model
248 # need special handling when creating new batch
249 if self.creating and issubclass(form.model_class, model.BatchMixin):
251 # first get the "normal" objectified batch. this will have
252 # all attributes set correctly per the form data, but will
253 # not yet belong to the db session. we ultimately discard it.
254 schema = form.get_schema()
255 batch = schema.objectify(form.validated, context=form.model_instance)
257 # then we collect attributes from the new batch
258 kw = {
259 key: getattr(batch, key)
260 for key in form.validated
261 if hasattr(batch, key)
262 }
264 # and set attribute for user creating the batch
265 kw["created_by"] = self.request.user
267 # plus caller can override anything
268 kw.update(kwargs)
270 # finally let batch handler make the "real" batch
271 return self.batch_handler.make_batch(self.Session(), **kw)
273 # otherwise normal logic is fine
274 return super().objectify(form)
276 def redirect_after_create(self, result):
277 """
278 If the new batch requires initial population, we launch a
279 thread for that and show the "progress" page.
281 Otherwise this will do the normal thing of redirecting to the
282 "view" page for the new batch.
283 """
284 batch = result
286 # just view batch if should not populate
287 if not self.batch_handler.should_populate(batch):
288 return self.redirect(self.get_action_url("view", batch))
290 # setup thread to populate batch
291 route_prefix = self.get_route_prefix()
292 key = f"{route_prefix}.populate"
293 progress = self.make_progress(
294 key, success_url=self.get_action_url("view", batch)
295 )
296 thread = threading.Thread(
297 target=self.populate_thread,
298 args=(batch.uuid,),
299 kwargs={"progress": progress},
300 )
302 # start thread and show progress page
303 thread.start()
304 return self.render_progress(progress)
306 def delete_instance(self, obj):
307 """
308 Delete the given batch instance.
310 This calls
311 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()`
312 on the :attr:`batch_handler`.
313 """
314 batch = obj
315 self.batch_handler.do_delete(batch, self.request.user)
317 ##############################
318 # populate methods
319 ##############################
321 def populate_thread(self, batch_uuid, progress=None):
322 """
323 Thread target for populating new object with progress indicator.
325 When a new batch is created, and the batch handler says it
326 should also be populated, then this thread is launched to do
327 so outside of the main request/response cycle. Progress bar
328 is then shown to the user until it completes.
330 This method mostly just calls
331 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
332 on the :term:`batch handler`.
333 """
334 # nb. must use our own session in separate thread
335 session = self.app.make_session()
337 # nb. main web request which created the batch, must complete
338 # before that session is committed. until that happens we
339 # will not be able to see the new batch. hence this loop,
340 # where we wait for the batch to appear.
341 batch = None
342 tries = 0
343 while not batch:
344 batch = session.get(self.model_class, batch_uuid)
345 tries += 1
346 if tries > 10:
347 raise RuntimeError("can't find the batch")
348 time.sleep(0.1)
350 def onerror():
351 log.warning(
352 "failed to populate %s: %s",
353 self.get_model_title(),
354 batch,
355 exc_info=True,
356 )
358 self.do_thread_body(
359 self.batch_handler.do_populate,
360 (batch,),
361 {"progress": progress},
362 onerror,
363 session=session,
364 progress=progress,
365 )
367 ##############################
368 # execute methods
369 ##############################
371 def execute(self):
372 """
373 View to execute the current :term:`batch`.
375 Eventually this should show a progress indicator etc., but for
376 now it simply calls
377 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
378 on the :attr:`batch_handler` and waits for it to complete,
379 then redirects user back to the "view batch" page.
380 """
381 self.executing = True
382 batch = self.get_instance()
384 try:
385 self.batch_handler.do_execute(batch, self.request.user)
386 except Exception as error: # pylint: disable=broad-exception-caught
387 log.warning("failed to execute batch: %s", batch, exc_info=True)
388 self.request.session.flash(f"Execution failed!: {error}", "error")
390 return self.redirect(self.get_action_url("view", batch))
392 ##############################
393 # row methods
394 ##############################
396 @classmethod
397 def get_row_model_class(cls): # pylint: disable=empty-docstring
398 """ """
399 if cls.row_model_class:
400 return cls.row_model_class
402 model_class = cls.get_model_class()
403 if model_class and hasattr(model_class, "__row_class__"):
404 return model_class.__row_class__
406 return None
408 def get_row_parent(self, row):
409 """
410 This overrides the parent method
411 :meth:`~wuttaweb.views.master.MasterView.get_row_parent()` to
412 return the batch to which the given row belongs.
413 """
414 return row.batch
416 def get_row_grid_data(self, obj):
417 """
418 Returns the base query for the batch
419 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
420 data.
421 """
422 session = self.Session()
423 batch = obj
424 row_model_class = self.get_row_model_class()
425 query = session.query(row_model_class).filter(row_model_class.batch == batch)
426 return query
428 def configure_row_grid(self, grid): # pylint: disable=empty-docstring
429 """ """
430 g = grid
431 super().configure_row_grid(g)
432 batch = self.get_instance()
434 g.remove("batch", "status_text")
436 # sequence
437 g.set_label("sequence", "Seq.", column_only=True)
438 if "sequence" in g.columns:
439 i = g.columns.index("sequence")
440 if i > 0:
441 g.columns.remove("sequence")
442 g.columns.insert(0, "sequence")
444 # status_code
445 g.set_renderer("status_code", self.render_row_status)
447 # tool button - create row
448 if self.rows_creatable and not batch.executed and self.has_perm("create_row"):
449 button = self.make_button(
450 f"New {self.get_row_model_title()}",
451 primary=True,
452 icon_left="plus",
453 url=self.get_action_url("create_row", batch),
454 )
455 g.add_tool(button, key="create_row")
457 def render_row_status( # pylint: disable=empty-docstring,unused-argument
458 self, row, key, value
459 ):
460 """ """
461 return row.STATUS.get(value, value)
463 def configure_row_form(self, form): # pylint: disable=empty-docstring
464 """ """
465 f = form
466 super().configure_row_form(f)
468 f.remove("batch", "status_text")
470 # sequence
471 if self.creating:
472 f.remove("sequence")
473 else:
474 f.set_readonly("sequence")
476 # status_code
477 if self.creating:
478 f.remove("status_code")
479 else:
480 f.set_readonly("status_code")
482 # modified
483 if self.creating:
484 f.remove("modified")
485 else:
486 f.set_readonly("modified")
488 def create_row_save_form(self, form):
489 """
490 Override of the parent method
491 :meth:`~wuttaweb.views.master.MasterView.create_row_save_form()`;
492 this does basically the same thing except it also will call
493 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.add_row()`
494 on the batch handler.
495 """
496 session = self.Session()
497 batch = self.get_instance()
498 row = self.objectify(form)
499 self.batch_handler.add_row(batch, row)
500 session.flush()
501 return row