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

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 

38 

39log = logging.getLogger(__name__) 

40 

41 

42class BatchMasterView(MasterView): 

43 """ 

44 Base class for all "batch master" views. 

45 

46 .. attribute:: batch_handler 

47 

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

49 

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

51 from :meth:`get_batch_handler()`. 

52 """ 

53 

54 executable = True 

55 

56 labels = { 

57 "id": "Batch ID", 

58 "status_code": "Status", 

59 } 

60 

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

62 

63 has_rows = True 

64 row_model_title = "Batch Row" 

65 rows_sort_defaults = "sequence" 

66 

67 row_labels = { 

68 "status_code": "Status", 

69 } 

70 

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

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

73 self.batch_handler = self.get_batch_handler() 

74 

75 def get_batch_handler(self): 

76 """ 

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

78 

79 There is no default logic; subclass must override. 

80 """ 

81 raise NotImplementedError 

82 

83 def get_fallback_templates(self, template): 

84 """ 

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

86 templates over the "master" templates. 

87 

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 

96 

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

104 

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) 

115 

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 ) 

123 

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

125 

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

127 """ """ 

128 g = grid 

129 super().configure_grid(g) 

130 model = self.app.model 

131 

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

142 

143 # id 

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

145 g.set_link("id") 

146 

147 # description 

148 g.set_link("description") 

149 

150 # status_code 

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

152 

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 

161 

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 

168 

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 

174 

175 # id 

176 if self.creating: 

177 f.remove("id") 

178 else: 

179 f.set_readonly("id") 

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

181 

182 # notes 

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

184 

185 # rows 

186 f.remove("rows") 

187 if self.creating: 

188 f.remove("row_count") 

189 else: 

190 f.set_readonly("row_count") 

191 

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

199 

200 # created 

201 if self.creating: 

202 f.remove("created") 

203 else: 

204 f.set_readonly("created") 

205 

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

213 

214 # executed 

215 if self.creating or not batch.executed: 

216 f.remove("executed") 

217 else: 

218 f.set_readonly("executed") 

219 

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

227 

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 

235 

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. 

242 

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

244 the ``make_batch()`` call. 

245 """ 

246 model = self.app.model 

247 

248 # need special handling when creating new batch 

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

250 

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) 

256 

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 } 

263 

264 # and set attribute for user creating the batch 

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

266 

267 # plus caller can override anything 

268 kw.update(kwargs) 

269 

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

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

272 

273 # otherwise normal logic is fine 

274 return super().objectify(form) 

275 

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. 

280 

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

282 "view" page for the new batch. 

283 """ 

284 batch = result 

285 

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

289 

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 ) 

301 

302 # start thread and show progress page 

303 thread.start() 

304 return self.render_progress(progress) 

305 

306 def delete_instance(self, obj): 

307 """ 

308 Delete the given batch instance. 

309 

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) 

316 

317 ############################## 

318 # populate methods 

319 ############################## 

320 

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

322 """ 

323 Thread target for populating new object with progress indicator. 

324 

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. 

329 

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

336 

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) 

349 

350 def onerror(): 

351 log.warning( 

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

353 self.get_model_title(), 

354 batch, 

355 exc_info=True, 

356 ) 

357 

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 ) 

366 

367 ############################## 

368 # execute methods 

369 ############################## 

370 

371 def execute(self): 

372 """ 

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

374 

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

383 

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

389 

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

391 

392 ############################## 

393 # row methods 

394 ############################## 

395 

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 

401 

402 model_class = cls.get_model_class() 

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

404 return model_class.__row_class__ 

405 

406 return None 

407 

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 

415 

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 

427 

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

433 

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

435 

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

443 

444 # status_code 

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

446 

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

456 

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) 

462 

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

464 """ """ 

465 f = form 

466 super().configure_row_form(f) 

467 

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

469 

470 # sequence 

471 if self.creating: 

472 f.remove("sequence") 

473 else: 

474 f.set_readonly("sequence") 

475 

476 # status_code 

477 if self.creating: 

478 f.remove("status_code") 

479 else: 

480 f.set_readonly("status_code") 

481 

482 # modified 

483 if self.creating: 

484 f.remove("modified") 

485 else: 

486 f.set_readonly("modified") 

487 

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