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

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

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 

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 labels = { 

55 'id': "Batch ID", 

56 'status_code': "Status", 

57 } 

58 

59 sort_defaults = ('id', 'desc') 

60 

61 has_rows = True 

62 rows_title = "Batch Rows" 

63 rows_sort_defaults = 'sequence' 

64 

65 row_labels = { 

66 'status_code': "Status", 

67 } 

68 

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

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

71 self.batch_handler = self.get_batch_handler() 

72 

73 def get_batch_handler(self): 

74 """ 

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

76 

77 There is no default logic; subclass must override. 

78 """ 

79 raise NotImplementedError 

80 

81 def get_fallback_templates(self, template): 

82 """ 

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

84 templates over the "master" templates. 

85 

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 

94 

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

102 

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) 

113 

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

118 

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

120 

121 def configure_grid(self, g): 

122 """ """ 

123 super().configure_grid(g) 

124 model = self.app.model 

125 

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

133 

134 # id 

135 g.set_renderer('id', self.render_batch_id) 

136 g.set_link('id') 

137 

138 # description 

139 g.set_link('description') 

140 

141 def render_batch_id(self, batch, key, value): 

142 """ """ 

143 if value: 

144 batch_id = int(value) 

145 return f'{batch_id:08d}' 

146 

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 

152 

153 def configure_form(self, f): 

154 """ """ 

155 super().configure_form(f) 

156 batch = f.model_instance 

157 

158 # id 

159 if self.creating: 

160 f.remove('id') 

161 else: 

162 f.set_readonly('id') 

163 f.set_widget('id', BatchIdWidget()) 

164 

165 # notes 

166 f.set_widget('notes', 'notes') 

167 

168 # rows 

169 f.remove('rows') 

170 if self.creating: 

171 f.remove('row_count') 

172 else: 

173 f.set_readonly('row_count') 

174 

175 # status 

176 f.remove('status_text') 

177 if self.creating: 

178 f.remove('status_code') 

179 else: 

180 f.set_readonly('status_code') 

181 

182 # created 

183 if self.creating: 

184 f.remove('created') 

185 else: 

186 f.set_readonly('created') 

187 

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

195 

196 # executed 

197 if self.creating or not batch.executed: 

198 f.remove('executed') 

199 else: 

200 f.set_readonly('executed') 

201 

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

209 

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. 

216 

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

218 the ``make_batch()`` call. 

219 """ 

220 if self.creating: 

221 

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) 

227 

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

232 

233 # and set attribute for user creating the batch 

234 kw['created_by'] = self.request.user 

235 

236 # plus caller can override anything 

237 kw.update(kwargs) 

238 

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

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

241 

242 # when not creating, normal logic is fine 

243 return super().objectify(form) 

244 

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. 

249 

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

256 

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

264 

265 # start thread and show progress page 

266 thread.start() 

267 return self.render_progress(progress) 

268 

269 def delete_instance(self, batch): 

270 """ 

271 Delete the given batch instance. 

272 

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) 

278 

279 ############################## 

280 # populate methods 

281 ############################## 

282 

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

284 """ 

285 Thread target for populating new object with progress indicator. 

286 

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. 

291 

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

298 

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) 

311 

312 try: 

313 # populate the batch 

314 self.batch_handler.do_populate(batch, progress=progress) 

315 session.flush() 

316 

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) 

324 

325 else: 

326 session.commit() 

327 if progress: 

328 progress.handle_success() 

329 

330 finally: 

331 session.close() 

332 

333 ############################## 

334 # execute methods 

335 ############################## 

336 

337 def execute(self): 

338 """ 

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

340 

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

349 

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

355 

356 return self.redirect(self.get_action_url('view', batch)) 

357 

358 ############################## 

359 # row methods 

360 ############################## 

361 

362 @classmethod 

363 def get_row_model_class(cls): 

364 """ """ 

365 if hasattr(cls, 'row_model_class'): 

366 return cls.row_model_class 

367 

368 Batch = cls.get_model_class() 

369 return Batch.__row_class__ 

370 

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 

381 

382 def configure_row_grid(self, g): 

383 """ """ 

384 super().configure_row_grid(g) 

385 

386 g.set_label('sequence', "Seq.", column_only=True) 

387 

388 g.set_renderer('status_code', self.render_row_status) 

389 

390 def render_row_status(self, row, key, value): 

391 """ """ 

392 return row.STATUS.get(value, value) 

393 

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

395 # configuration 

396 ############################## 

397 

398 @classmethod 

399 def defaults(cls, config): 

400 """ """ 

401 cls._defaults(config) 

402 cls._batch_defaults(config) 

403 

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

410 

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