Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/batch.py: 100%
191 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
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
38log = logging.getLogger(__name__)
41class BatchMasterView(MasterView): # pylint: disable=too-many-public-methods
42 """
43 Base class for all "batch master" views.
45 .. attribute:: batch_handler
47 Reference to the :term:`batch handler` for use with the view.
49 This is set when the view is first created, using return value
50 from :meth:`get_batch_handler()`.
51 """
53 executable = True
55 labels = {
56 "id": "Batch ID",
57 "status_code": "Status",
58 }
60 sort_defaults = ("id", "desc")
62 has_rows = True
63 row_model_title = "Batch Row"
64 rows_sort_defaults = "sequence"
66 row_labels = {
67 "status_code": "Status",
68 }
70 def __init__(self, request, context=None):
71 super().__init__(request, context=context)
72 self.batch_handler = self.get_batch_handler()
74 def get_batch_handler(self):
75 """
76 Must return the :term:`batch handler` for use with this view.
78 There is no default logic; subclass must override.
79 """
80 raise NotImplementedError
82 def get_fallback_templates(self, template):
83 """
84 We override the default logic here, to prefer "batch"
85 templates over the "master" templates.
87 So for instance the "view batch" page will by default use the
88 ``/batch/view.mako`` template - which does inherit from
89 ``/master/view.mako`` but adds extra features specific to
90 batches.
91 """
92 templates = super().get_fallback_templates(template)
93 templates.insert(0, f"/batch/{template}.mako")
94 return templates
96 def render_to_response(self, template, context):
97 """
98 We override the default logic here, to inject batch-related
99 context for the
100 :meth:`~wuttaweb.views.master.MasterView.view()` template
101 specifically. These values are used in the template file,
102 ``/batch/view.mako``.
104 * ``batch`` - reference to the current :term:`batch`
105 * ``batch_handler`` reference to :attr:`batch_handler`
106 * ``why_not_execute`` - text of reason (if any) not to execute batch
107 * ``execution_described`` - HTML (rendered from markdown) describing batch execution
108 """
109 if template == "view":
110 batch = context["instance"]
111 context["batch"] = batch
112 context["batch_handler"] = self.batch_handler
113 context["why_not_execute"] = self.batch_handler.why_not_execute(batch)
115 description = (
116 self.batch_handler.describe_execution(batch)
117 or "Handler does not say! Your guess is as good as mine."
118 )
119 context["execution_described"] = markdown.markdown(
120 description, extensions=["fenced_code", "codehilite"]
121 )
123 return super().render_to_response(template, context)
125 def configure_grid(self, grid): # pylint: disable=empty-docstring
126 """ """
127 g = grid
128 super().configure_grid(g)
129 model = self.app.model
131 # created_by
132 CreatedBy = orm.aliased(model.User) # pylint: disable=invalid-name
133 g.set_joiner(
134 "created_by",
135 lambda q: q.join(
136 CreatedBy, CreatedBy.uuid == self.model_class.created_by_uuid
137 ),
138 )
139 g.set_sorter("created_by", CreatedBy.username)
140 # g.set_filter('created_by', CreatedBy.username, label="Created By Username")
142 # id
143 g.set_renderer("id", self.render_batch_id)
144 g.set_link("id")
146 # description
147 g.set_link("description")
149 # status_code
150 g.set_enum("status_code", self.model_class.STATUS)
152 def render_batch_id( # pylint: disable=empty-docstring,unused-argument
153 self, batch, key, value
154 ):
155 """ """
156 if value:
157 batch_id = int(value)
158 return f"{batch_id:08d}"
159 return None
161 def get_instance_title(self, instance): # pylint: disable=empty-docstring
162 """ """
163 batch = instance
164 if batch.description:
165 return f"{batch.id_str} {batch.description}"
166 return batch.id_str
168 def configure_form(self, form): # pylint: disable=too-many-branches,empty-docstring
169 """ """
170 super().configure_form(form)
171 f = form
172 batch = f.model_instance
174 # id
175 if self.creating:
176 f.remove("id")
177 else:
178 f.set_readonly("id")
179 f.set_widget("id", BatchIdWidget())
181 # notes
182 f.set_widget("notes", "notes")
184 # rows
185 f.remove("rows")
186 if self.creating:
187 f.remove("row_count")
188 else:
189 f.set_readonly("row_count")
191 # status
192 f.remove("status_text")
193 if self.creating:
194 f.remove("status_code")
195 else:
196 f.set_node("status_code", WuttaDictEnum(self.request, batch.STATUS))
197 f.set_readonly("status_code")
199 # created
200 if self.creating:
201 f.remove("created")
202 else:
203 f.set_readonly("created")
205 # created_by
206 f.remove("created_by_uuid")
207 if self.creating:
208 f.remove("created_by")
209 else:
210 f.set_node("created_by", UserRef(self.request))
211 f.set_readonly("created_by")
213 # executed
214 if self.creating or not batch.executed:
215 f.remove("executed")
216 else:
217 f.set_readonly("executed")
219 # executed_by
220 f.remove("executed_by_uuid")
221 if self.creating or not batch.executed:
222 f.remove("executed_by")
223 else:
224 f.set_node("executed_by", UserRef(self.request))
225 f.set_readonly("executed_by")
227 def is_editable(self, batch): # pylint: disable=arguments-renamed
228 """
229 This overrides the parent method
230 :meth:`~wuttaweb.views.master.MasterView.is_editable()` to
231 return ``False`` if the batch has already been executed.
232 """
233 return not batch.executed
235 def objectify(self, form, **kwargs):
236 """
237 We override the default logic here, to invoke
238 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
239 on the batch handler - when creating. Parent/default logic is
240 used when updating.
242 :param \\**kwargs: Additional kwargs will be passed as-is to
243 the ``make_batch()`` call.
244 """
245 model = self.app.model
247 # need special handling when creating new batch
248 if self.creating and issubclass(form.model_class, model.BatchMixin):
250 # first get the "normal" objectified batch. this will have
251 # all attributes set correctly per the form data, but will
252 # not yet belong to the db session. we ultimately discard it.
253 schema = form.get_schema()
254 batch = schema.objectify(form.validated, context=form.model_instance)
256 # then we collect attributes from the new batch
257 kw = {
258 key: getattr(batch, key)
259 for key in form.validated
260 if hasattr(batch, key)
261 }
263 # and set attribute for user creating the batch
264 kw["created_by"] = self.request.user
266 # plus caller can override anything
267 kw.update(kwargs)
269 # finally let batch handler make the "real" batch
270 return self.batch_handler.make_batch(self.Session(), **kw)
272 # otherwise normal logic is fine
273 return super().objectify(form)
275 def redirect_after_create(self, result):
276 """
277 If the new batch requires initial population, we launch a
278 thread for that and show the "progress" page.
280 Otherwise this will do the normal thing of redirecting to the
281 "view" page for the new batch.
282 """
283 batch = result
285 # just view batch if should not populate
286 if not self.batch_handler.should_populate(batch):
287 return self.redirect(self.get_action_url("view", batch))
289 # setup thread to populate batch
290 route_prefix = self.get_route_prefix()
291 key = f"{route_prefix}.populate"
292 progress = self.make_progress(
293 key, success_url=self.get_action_url("view", batch)
294 )
295 thread = threading.Thread(
296 target=self.populate_thread,
297 args=(batch.uuid,),
298 kwargs={"progress": progress},
299 )
301 # start thread and show progress page
302 thread.start()
303 return self.render_progress(progress)
305 def delete_instance(self, obj):
306 """
307 Delete the given batch instance.
309 This calls
310 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()`
311 on the :attr:`batch_handler`.
312 """
313 batch = obj
314 self.batch_handler.do_delete(batch, self.request.user)
316 ##############################
317 # populate methods
318 ##############################
320 def populate_thread(self, batch_uuid, progress=None):
321 """
322 Thread target for populating new object with progress indicator.
324 When a new batch is created, and the batch handler says it
325 should also be populated, then this thread is launched to do
326 so outside of the main request/response cycle. Progress bar
327 is then shown to the user until it completes.
329 This method mostly just calls
330 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
331 on the :term:`batch handler`.
332 """
333 # nb. must use our own session in separate thread
334 session = self.app.make_session()
336 # nb. main web request which created the batch, must complete
337 # before that session is committed. until that happens we
338 # will not be able to see the new batch. hence this loop,
339 # where we wait for the batch to appear.
340 batch = None
341 tries = 0
342 while not batch:
343 batch = session.get(self.model_class, batch_uuid)
344 tries += 1
345 if tries > 10:
346 raise RuntimeError("can't find the batch")
347 time.sleep(0.1)
349 def onerror():
350 log.warning(
351 "failed to populate %s: %s",
352 self.get_model_title(),
353 batch,
354 exc_info=True,
355 )
357 self.do_thread_body(
358 self.batch_handler.do_populate,
359 (batch,),
360 {"progress": progress},
361 onerror,
362 session=session,
363 progress=progress,
364 )
366 ##############################
367 # execute methods
368 ##############################
370 def execute(self):
371 """
372 View to execute the current :term:`batch`.
374 Eventually this should show a progress indicator etc., but for
375 now it simply calls
376 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
377 on the :attr:`batch_handler` and waits for it to complete,
378 then redirects user back to the "view batch" page.
379 """
380 self.executing = True
381 batch = self.get_instance()
383 try:
384 self.batch_handler.do_execute(batch, self.request.user)
385 except Exception as error: # pylint: disable=broad-exception-caught
386 log.warning("failed to execute batch: %s", batch, exc_info=True)
387 self.request.session.flash(f"Execution failed!: {error}", "error")
389 return self.redirect(self.get_action_url("view", batch))
391 ##############################
392 # row methods
393 ##############################
395 @classmethod
396 def get_row_model_class(cls): # pylint: disable=empty-docstring
397 """ """
398 if cls.row_model_class:
399 return cls.row_model_class
401 model_class = cls.get_model_class()
402 if model_class and hasattr(model_class, "__row_class__"):
403 return model_class.__row_class__
405 return None
407 def get_row_parent(self, row):
408 """
409 This overrides the parent method
410 :meth:`~wuttaweb.views.master.MasterView.get_row_parent()` to
411 return the batch to which the given row belongs.
412 """
413 return row.batch
415 def get_row_grid_data(self, obj):
416 """
417 Returns the base query for the batch
418 :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
419 data.
420 """
421 session = self.Session()
422 batch = obj
423 row_model_class = self.get_row_model_class()
424 query = session.query(row_model_class).filter(row_model_class.batch == batch)
425 return query
427 def configure_row_grid(self, grid): # pylint: disable=empty-docstring
428 """ """
429 g = grid
430 super().configure_row_grid(g)
432 g.remove("batch", "status_text")
434 # sequence
435 g.set_label("sequence", "Seq.", column_only=True)
436 if "sequence" in g.columns:
437 i = g.columns.index("sequence")
438 if i > 0:
439 g.columns.remove("sequence")
440 g.columns.insert(0, "sequence")
442 # status_code
443 g.set_renderer("status_code", self.render_row_status)
445 def allow_create_row(self, obj):
446 """
447 In addition to typical checks, this checks if the batch has
448 already been executed, and if so, create row is not allowed.
450 See also parent method:
451 :meth:`~wuttaweb.views.master.MasterView.allow_create_row()`
452 """
453 batch = obj
455 if batch.executed:
456 return False
458 return super().allow_create_row(batch)
460 def render_row_status( # pylint: disable=empty-docstring,unused-argument
461 self, row, key, value
462 ):
463 """ """
464 return row.STATUS.get(value, value)
466 def configure_row_form(self, form): # pylint: disable=empty-docstring
467 """ """
468 f = form
469 super().configure_row_form(f)
471 f.remove("batch", "status_text")
473 # sequence
474 if self.creating:
475 f.remove("sequence")
476 else:
477 f.set_readonly("sequence")
479 # status_code
480 if self.creating:
481 f.remove("status_code")
482 else:
483 f.set_readonly("status_code")
485 # modified
486 if self.creating:
487 f.remove("modified")
488 else:
489 f.set_readonly("modified")
491 def create_row_save_form(self, form):
492 """
493 Override of the parent method
494 :meth:`~wuttaweb.views.master.MasterView.create_row_save_form()`;
495 this does basically the same thing except it also will call
496 :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.add_row()`
497 on the batch handler.
498 """
499 session = self.Session()
500 batch = self.get_instance()
501 row = self.objectify(form)
502 self.batch_handler.add_row(batch, row)
503 session.flush()
504 return row