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

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""" 

26 

27import logging 

28import threading 

29import time 

30 

31import markdown 

32from sqlalchemy import orm 

33 

34from wuttaweb.views import MasterView 

35from wuttaweb.forms.schema import UserRef, WuttaDictEnum 

36from wuttaweb.forms.widgets import BatchIdWidget 

37 

38log = logging.getLogger(__name__) 

39 

40 

41class BatchMasterView(MasterView): # pylint: disable=too-many-public-methods 

42 """ 

43 Base class for all "batch master" views. 

44 

45 .. attribute:: batch_handler 

46 

47 Reference to the :term:`batch handler` for use with the view. 

48 

49 This is set when the view is first created, using return value 

50 from :meth:`get_batch_handler()`. 

51 """ 

52 

53 executable = True 

54 

55 labels = { 

56 "id": "Batch ID", 

57 "status_code": "Status", 

58 } 

59 

60 sort_defaults = ("id", "desc") 

61 

62 has_rows = True 

63 row_model_title = "Batch Row" 

64 rows_sort_defaults = "sequence" 

65 

66 row_labels = { 

67 "status_code": "Status", 

68 } 

69 

70 def __init__(self, request, context=None): 

71 super().__init__(request, context=context) 

72 self.batch_handler = self.get_batch_handler() 

73 

74 def get_batch_handler(self): 

75 """ 

76 Must return the :term:`batch handler` for use with this view. 

77 

78 There is no default logic; subclass must override. 

79 """ 

80 raise NotImplementedError 

81 

82 def get_fallback_templates(self, template): 

83 """ 

84 We override the default logic here, to prefer "batch" 

85 templates over the "master" templates. 

86 

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 

95 

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``. 

103 

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) 

114 

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 ) 

122 

123 return super().render_to_response(template, context) 

124 

125 def configure_grid(self, grid): # pylint: disable=empty-docstring 

126 """ """ 

127 g = grid 

128 super().configure_grid(g) 

129 model = self.app.model 

130 

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") 

141 

142 # id 

143 g.set_renderer("id", self.render_batch_id) 

144 g.set_link("id") 

145 

146 # description 

147 g.set_link("description") 

148 

149 # status_code 

150 g.set_enum("status_code", self.model_class.STATUS) 

151 

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 

160 

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 

167 

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 

173 

174 # id 

175 if self.creating: 

176 f.remove("id") 

177 else: 

178 f.set_readonly("id") 

179 f.set_widget("id", BatchIdWidget()) 

180 

181 # notes 

182 f.set_widget("notes", "notes") 

183 

184 # rows 

185 f.remove("rows") 

186 if self.creating: 

187 f.remove("row_count") 

188 else: 

189 f.set_readonly("row_count") 

190 

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") 

198 

199 # created 

200 if self.creating: 

201 f.remove("created") 

202 else: 

203 f.set_readonly("created") 

204 

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") 

212 

213 # executed 

214 if self.creating or not batch.executed: 

215 f.remove("executed") 

216 else: 

217 f.set_readonly("executed") 

218 

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") 

226 

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 

234 

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. 

241 

242 :param \\**kwargs: Additional kwargs will be passed as-is to 

243 the ``make_batch()`` call. 

244 """ 

245 model = self.app.model 

246 

247 # need special handling when creating new batch 

248 if self.creating and issubclass(form.model_class, model.BatchMixin): 

249 

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) 

255 

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 } 

262 

263 # and set attribute for user creating the batch 

264 kw["created_by"] = self.request.user 

265 

266 # plus caller can override anything 

267 kw.update(kwargs) 

268 

269 # finally let batch handler make the "real" batch 

270 return self.batch_handler.make_batch(self.Session(), **kw) 

271 

272 # otherwise normal logic is fine 

273 return super().objectify(form) 

274 

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. 

279 

280 Otherwise this will do the normal thing of redirecting to the 

281 "view" page for the new batch. 

282 """ 

283 batch = result 

284 

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)) 

288 

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 ) 

300 

301 # start thread and show progress page 

302 thread.start() 

303 return self.render_progress(progress) 

304 

305 def delete_instance(self, obj): 

306 """ 

307 Delete the given batch instance. 

308 

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) 

315 

316 ############################## 

317 # populate methods 

318 ############################## 

319 

320 def populate_thread(self, batch_uuid, progress=None): 

321 """ 

322 Thread target for populating new object with progress indicator. 

323 

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. 

328 

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() 

335 

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) 

348 

349 def onerror(): 

350 log.warning( 

351 "failed to populate %s: %s", 

352 self.get_model_title(), 

353 batch, 

354 exc_info=True, 

355 ) 

356 

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 ) 

365 

366 ############################## 

367 # execute methods 

368 ############################## 

369 

370 def execute(self): 

371 """ 

372 View to execute the current :term:`batch`. 

373 

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() 

382 

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") 

388 

389 return self.redirect(self.get_action_url("view", batch)) 

390 

391 ############################## 

392 # row methods 

393 ############################## 

394 

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 

400 

401 model_class = cls.get_model_class() 

402 if model_class and hasattr(model_class, "__row_class__"): 

403 return model_class.__row_class__ 

404 

405 return None 

406 

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 

414 

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 

426 

427 def configure_row_grid(self, grid): # pylint: disable=empty-docstring 

428 """ """ 

429 g = grid 

430 super().configure_row_grid(g) 

431 

432 g.remove("batch", "status_text") 

433 

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") 

441 

442 # status_code 

443 g.set_renderer("status_code", self.render_row_status) 

444 

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. 

449 

450 See also parent method: 

451 :meth:`~wuttaweb.views.master.MasterView.allow_create_row()` 

452 """ 

453 batch = obj 

454 

455 if batch.executed: 

456 return False 

457 

458 return super().allow_create_row(batch) 

459 

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) 

465 

466 def configure_row_form(self, form): # pylint: disable=empty-docstring 

467 """ """ 

468 f = form 

469 super().configure_row_form(f) 

470 

471 f.remove("batch", "status_text") 

472 

473 # sequence 

474 if self.creating: 

475 f.remove("sequence") 

476 else: 

477 f.set_readonly("sequence") 

478 

479 # status_code 

480 if self.creating: 

481 f.remove("status_code") 

482 else: 

483 f.set_readonly("status_code") 

484 

485 # modified 

486 if self.creating: 

487 f.remove("modified") 

488 else: 

489 f.set_readonly("modified") 

490 

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