Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/master.py: 100%

619 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-26 14:40 -0500

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 Master Views 

25""" 

26 

27import logging 

28import os 

29import threading 

30 

31import sqlalchemy as sa 

32from sqlalchemy import orm 

33 

34from pyramid.renderers import render_to_response 

35from webhelpers2.html import HTML 

36 

37from wuttaweb.views import View 

38from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token 

39from wuttaweb.db import Session 

40from wuttaweb.progress import SessionProgress 

41from wuttjamaican.util import get_class_hierarchy 

42 

43 

44log = logging.getLogger(__name__) 

45 

46 

47class MasterView(View): 

48 """ 

49 Base class for "master" views. 

50 

51 Master views typically map to a table in a DB, though not always. 

52 They essentially are a set of CRUD views for a certain type of 

53 data record. 

54 

55 Many attributes may be overridden in subclass. For instance to 

56 define :attr:`model_class`:: 

57 

58 from wuttaweb.views import MasterView 

59 from wuttjamaican.db.model import Person 

60 

61 class MyPersonView(MasterView): 

62 model_class = Person 

63 

64 def includeme(config): 

65 MyPersonView.defaults(config) 

66 

67 .. note:: 

68 

69 Many of these attributes will only exist if they have been 

70 explicitly defined in a subclass. There are corresponding 

71 ``get_xxx()`` methods which should be used instead of accessing 

72 these attributes directly. 

73 

74 .. attribute:: model_class 

75 

76 Optional reference to a data model class. While not strictly 

77 required, most views will set this to a SQLAlchemy mapped 

78 class, 

79 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 

80 

81 Code should not access this directly but instead call 

82 :meth:`get_model_class()`. 

83 

84 .. attribute:: model_name 

85 

86 Optional override for the view's data model name, 

87 e.g. ``'WuttaWidget'``. 

88 

89 Code should not access this directly but instead call 

90 :meth:`get_model_name()`. 

91 

92 .. attribute:: model_name_normalized 

93 

94 Optional override for the view's "normalized" data model name, 

95 e.g. ``'wutta_widget'``. 

96 

97 Code should not access this directly but instead call 

98 :meth:`get_model_name_normalized()`. 

99 

100 .. attribute:: model_title 

101 

102 Optional override for the view's "humanized" (singular) model 

103 title, e.g. ``"Wutta Widget"``. 

104 

105 Code should not access this directly but instead call 

106 :meth:`get_model_title()`. 

107 

108 .. attribute:: model_title_plural 

109 

110 Optional override for the view's "humanized" (plural) model 

111 title, e.g. ``"Wutta Widgets"``. 

112 

113 Code should not access this directly but instead call 

114 :meth:`get_model_title_plural()`. 

115 

116 .. attribute:: model_key 

117 

118 Optional override for the view's "model key" - e.g. ``'id'`` 

119 (string for simple case) or composite key such as 

120 ``('id_field', 'name_field')``. 

121 

122 If :attr:`model_class` is set to a SQLAlchemy mapped class, the 

123 model key can be determined automatically. 

124 

125 Code should not access this directly but instead call 

126 :meth:`get_model_key()`. 

127 

128 .. attribute:: grid_key 

129 

130 Optional override for the view's grid key, e.g. ``'widgets'``. 

131 

132 Code should not access this directly but instead call 

133 :meth:`get_grid_key()`. 

134 

135 .. attribute:: config_title 

136 

137 Optional override for the view's "config" title, e.g. ``"Wutta 

138 Widgets"`` (to be displayed as **Configure Wutta Widgets**). 

139 

140 Code should not access this directly but instead call 

141 :meth:`get_config_title()`. 

142 

143 .. attribute:: route_prefix 

144 

145 Optional override for the view's route prefix, 

146 e.g. ``'wutta_widgets'``. 

147 

148 Code should not access this directly but instead call 

149 :meth:`get_route_prefix()`. 

150 

151 .. attribute:: permission_prefix 

152 

153 Optional override for the view's permission prefix, 

154 e.g. ``'wutta_widgets'``. 

155 

156 Code should not access this directly but instead call 

157 :meth:`get_permission_prefix()`. 

158 

159 .. attribute:: url_prefix 

160 

161 Optional override for the view's URL prefix, 

162 e.g. ``'/widgets'``. 

163 

164 Code should not access this directly but instead call 

165 :meth:`get_url_prefix()`. 

166 

167 .. attribute:: template_prefix 

168 

169 Optional override for the view's template prefix, 

170 e.g. ``'/widgets'``. 

171 

172 Code should not access this directly but instead call 

173 :meth:`get_template_prefix()`. 

174 

175 .. attribute:: listable 

176 

177 Boolean indicating whether the view model supports "listing" - 

178 i.e. it should have an :meth:`index()` view. Default value is 

179 ``True``. 

180 

181 .. attribute:: has_grid 

182 

183 Boolean indicating whether the :meth:`index()` view should 

184 include a grid. Default value is ``True``. 

185 

186 .. attribute:: grid_columns 

187 

188 List of columns for the :meth:`index()` view grid. 

189 

190 This is optional; see also :meth:`get_grid_columns()`. 

191 

192 .. method:: grid_row_class(obj, data, i) 

193 

194 This method is *not* defined on the ``MasterView`` base class; 

195 however if a subclass defines it then it will be automatically 

196 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for 

197 the main :meth:`index()` grid. 

198 

199 For more info see 

200 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`. 

201 

202 .. attribute:: filterable 

203 

204 Boolean indicating whether the grid for the :meth:`index()` 

205 view should allow filtering of data. Default is ``True``. 

206 

207 This is used by :meth:`make_model_grid()` to set the grid's 

208 :attr:`~wuttaweb.grids.base.Grid.filterable` flag. 

209 

210 .. attribute:: filter_defaults 

211 

212 Optional dict of default filter state. 

213 

214 This is used by :meth:`make_model_grid()` to set the grid's 

215 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`. 

216 

217 Only relevant if :attr:`filterable` is true. 

218 

219 .. attribute:: sortable 

220 

221 Boolean indicating whether the grid for the :meth:`index()` 

222 view should allow sorting of data. Default is ``True``. 

223 

224 This is used by :meth:`make_model_grid()` to set the grid's 

225 :attr:`~wuttaweb.grids.base.Grid.sortable` flag. 

226 

227 See also :attr:`sort_on_backend` and :attr:`sort_defaults`. 

228 

229 .. attribute:: sort_on_backend 

230 

231 Boolean indicating whether the grid data for the 

232 :meth:`index()` view should be sorted on the backend. Default 

233 is ``True``. 

234 

235 This is used by :meth:`make_model_grid()` to set the grid's 

236 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag. 

237 

238 Only relevant if :attr:`sortable` is true. 

239 

240 .. attribute:: sort_defaults 

241 

242 Optional list of default sorting info. Applicable for both 

243 frontend and backend sorting. 

244 

245 This is used by :meth:`make_model_grid()` to set the grid's 

246 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`. 

247 

248 Only relevant if :attr:`sortable` is true. 

249 

250 .. attribute:: paginated 

251 

252 Boolean indicating whether the grid data for the 

253 :meth:`index()` view should be paginated. Default is ``True``. 

254 

255 This is used by :meth:`make_model_grid()` to set the grid's 

256 :attr:`~wuttaweb.grids.base.Grid.paginated` flag. 

257 

258 .. attribute:: paginate_on_backend 

259 

260 Boolean indicating whether the grid data for the 

261 :meth:`index()` view should be paginated on the backend. 

262 Default is ``True``. 

263 

264 This is used by :meth:`make_model_grid()` to set the grid's 

265 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag. 

266 

267 .. attribute:: creatable 

268 

269 Boolean indicating whether the view model supports "creating" - 

270 i.e. it should have a :meth:`create()` view. Default value is 

271 ``True``. 

272 

273 .. attribute:: viewable 

274 

275 Boolean indicating whether the view model supports "viewing" - 

276 i.e. it should have a :meth:`view()` view. Default value is 

277 ``True``. 

278 

279 .. attribute:: editable 

280 

281 Boolean indicating whether the view model supports "editing" - 

282 i.e. it should have an :meth:`edit()` view. Default value is 

283 ``True``. 

284 

285 See also :meth:`is_editable()`. 

286 

287 .. attribute:: deletable 

288 

289 Boolean indicating whether the view model supports "deleting" - 

290 i.e. it should have a :meth:`delete()` view. Default value is 

291 ``True``. 

292 

293 See also :meth:`is_deletable()`. 

294 

295 .. attribute:: deletable_bulk 

296 

297 Boolean indicating whether the view model supports "bulk 

298 deleting" - i.e. it should have a :meth:`delete_bulk()` view. 

299 Default value is ``False``. 

300 

301 See also :attr:`deletable_bulk_quick`. 

302 

303 .. attribute:: deletable_bulk_quick 

304 

305 Boolean indicating whether the view model supports "quick" bulk 

306 deleting, i.e. the operation is reliably quick enough that it 

307 should happen *synchronously* with no progress indicator. 

308 

309 Default is ``False`` in which case a progress indicator is 

310 shown while the bulk deletion is performed. 

311 

312 Only relevant if :attr:`deletable_bulk` is true. 

313 

314 .. attribute:: form_fields 

315 

316 List of fields for the model form. 

317 

318 This is optional; see also :meth:`get_form_fields()`. 

319 

320 .. attribute:: has_autocomplete 

321 

322 Boolean indicating whether the view model supports 

323 "autocomplete" - i.e. it should have an :meth:`autocomplete()` 

324 view. Default is ``False``. 

325 

326 .. attribute:: downloadable 

327 

328 Boolean indicating whether the view model supports 

329 "downloading" - i.e. it should have a :meth:`download()` view. 

330 Default is ``False``. 

331 

332 .. attribute:: executable 

333 

334 Boolean indicating whether the view model supports "executing" 

335 - i.e. it should have an :meth:`execute()` view. Default is 

336 ``False``. 

337 

338 .. attribute:: configurable 

339 

340 Boolean indicating whether the master view supports 

341 "configuring" - i.e. it should have a :meth:`configure()` view. 

342 Default value is ``False``. 

343 """ 

344 

345 ############################## 

346 # attributes 

347 ############################## 

348 

349 # features 

350 listable = True 

351 has_grid = True 

352 filterable = True 

353 filter_defaults = None 

354 sortable = True 

355 sort_on_backend = True 

356 sort_defaults = None 

357 paginated = True 

358 paginate_on_backend = True 

359 creatable = True 

360 viewable = True 

361 editable = True 

362 deletable = True 

363 deletable_bulk = False 

364 deletable_bulk_quick = False 

365 has_autocomplete = False 

366 downloadable = False 

367 executable = False 

368 execute_progress_template = None 

369 configurable = False 

370 

371 # current action 

372 listing = False 

373 creating = False 

374 viewing = False 

375 editing = False 

376 deleting = False 

377 configuring = False 

378 

379 # default DB session 

380 Session = Session 

381 

382 ############################## 

383 # index methods 

384 ############################## 

385 

386 def index(self): 

387 """ 

388 View to "list" (filter/browse) the model data. 

389 

390 This is the "default" view for the model and is what user sees 

391 when visiting the "root" path under the :attr:`url_prefix`, 

392 e.g. ``/widgets/``. 

393 

394 By default, this view is included only if :attr:`listable` is 

395 true. 

396 

397 The default view logic will show a "grid" (table) with the 

398 model data (unless :attr:`has_grid` is false). 

399 

400 See also related methods, which are called by this one: 

401 

402 * :meth:`make_model_grid()` 

403 """ 

404 self.listing = True 

405 

406 context = { 

407 'index_url': None, # nb. avoid title link since this *is* the index 

408 } 

409 

410 if self.has_grid: 

411 grid = self.make_model_grid() 

412 

413 # handle "full" vs. "partial" differently 

414 if self.request.GET.get('partial'): 

415 

416 # so-called 'partial' requests get just data, no html 

417 context = grid.get_vue_context() 

418 if grid.paginated and grid.paginate_on_backend: 

419 context['pager_stats'] = grid.get_vue_pager_stats() 

420 return self.json_response(context) 

421 

422 else: # full, not partial 

423 

424 # nb. when user asks to reset view, it is via the query 

425 # string. if so we then redirect to discard that. 

426 if self.request.GET.get('reset-view'): 

427 

428 # nb. we want to preserve url hash if applicable 

429 kw = {'_query': None, 

430 '_anchor': self.request.GET.get('hash')} 

431 return self.redirect(self.request.current_route_url(**kw)) 

432 

433 context['grid'] = grid 

434 

435 return self.render_to_response('index', context) 

436 

437 ############################## 

438 # create methods 

439 ############################## 

440 

441 def create(self): 

442 """ 

443 View to "create" a new model record. 

444 

445 This usually corresponds to a URL like ``/widgets/new``. 

446 

447 By default, this view is included only if :attr:`creatable` is 

448 true. 

449 

450 The default "create" view logic will show a form with field 

451 widgets, allowing user to submit new values which are then 

452 persisted to the DB (assuming typical SQLAlchemy model). 

453 

454 Subclass normally should not override this method, but rather 

455 one of the related methods which are called (in)directly by 

456 this one: 

457 

458 * :meth:`make_model_form()` 

459 * :meth:`configure_form()` 

460 * :meth:`create_save_form()` 

461 """ 

462 self.creating = True 

463 form = self.make_model_form(cancel_url_fallback=self.get_index_url()) 

464 

465 if form.validate(): 

466 obj = self.create_save_form(form) 

467 self.Session.flush() 

468 return self.redirect(self.get_action_url('view', obj)) 

469 

470 context = { 

471 'form': form, 

472 } 

473 return self.render_to_response('create', context) 

474 

475 def create_save_form(self, form): 

476 """ 

477 This method is responsible for "converting" the validated form 

478 data to a model instance, and then "saving" the result, 

479 e.g. to DB. It is called by :meth:`create()`. 

480 

481 Subclass may override this, or any of the related methods 

482 called by this one: 

483 

484 * :meth:`objectify()` 

485 * :meth:`persist()` 

486 

487 :returns: Should return the resulting model instance, e.g. as 

488 produced by :meth:`objectify()`. 

489 """ 

490 obj = self.objectify(form) 

491 self.persist(obj) 

492 return obj 

493 

494 ############################## 

495 # view methods 

496 ############################## 

497 

498 def view(self): 

499 """ 

500 View to "view" details of an existing model record. 

501 

502 This usually corresponds to a URL like ``/widgets/XXX`` 

503 where ``XXX`` represents the key/ID for the record. 

504 

505 By default, this view is included only if :attr:`viewable` is 

506 true. 

507 

508 The default view logic will show a read-only form with field 

509 values displayed. 

510 

511 Subclass normally should not override this method, but rather 

512 one of the related methods which are called (in)directly by 

513 this one: 

514 

515 * :meth:`make_model_form()` 

516 * :meth:`configure_form()` 

517 """ 

518 self.viewing = True 

519 instance = self.get_instance() 

520 form = self.make_model_form(instance, readonly=True) 

521 

522 context = { 

523 'instance': instance, 

524 'form': form, 

525 } 

526 return self.render_to_response('view', context) 

527 

528 ############################## 

529 # edit methods 

530 ############################## 

531 

532 def edit(self): 

533 """ 

534 View to "edit" details of an existing model record. 

535 

536 This usually corresponds to a URL like ``/widgets/XXX/edit`` 

537 where ``XXX`` represents the key/ID for the record. 

538 

539 By default, this view is included only if :attr:`editable` is 

540 true. 

541 

542 The default "edit" view logic will show a form with field 

543 widgets, allowing user to modify and submit new values which 

544 are then persisted to the DB (assuming typical SQLAlchemy 

545 model). 

546 

547 Subclass normally should not override this method, but rather 

548 one of the related methods which are called (in)directly by 

549 this one: 

550 

551 * :meth:`make_model_form()` 

552 * :meth:`configure_form()` 

553 * :meth:`edit_save_form()` 

554 """ 

555 self.editing = True 

556 instance = self.get_instance() 

557 

558 form = self.make_model_form(instance, 

559 cancel_url_fallback=self.get_action_url('view', instance)) 

560 

561 if form.validate(): 

562 self.edit_save_form(form) 

563 return self.redirect(self.get_action_url('view', instance)) 

564 

565 context = { 

566 'instance': instance, 

567 'form': form, 

568 } 

569 return self.render_to_response('edit', context) 

570 

571 def edit_save_form(self, form): 

572 """ 

573 This method is responsible for "converting" the validated form 

574 data to a model instance, and then "saving" the result, 

575 e.g. to DB. It is called by :meth:`edit()`. 

576 

577 Subclass may override this, or any of the related methods 

578 called by this one: 

579 

580 * :meth:`objectify()` 

581 * :meth:`persist()` 

582 

583 :returns: Should return the resulting model instance, e.g. as 

584 produced by :meth:`objectify()`. 

585 """ 

586 obj = self.objectify(form) 

587 self.persist(obj) 

588 return obj 

589 

590 ############################## 

591 # delete methods 

592 ############################## 

593 

594 def delete(self): 

595 """ 

596 View to delete an existing model instance. 

597 

598 This usually corresponds to a URL like ``/widgets/XXX/delete`` 

599 where ``XXX`` represents the key/ID for the record. 

600 

601 By default, this view is included only if :attr:`deletable` is 

602 true. 

603 

604 The default "delete" view logic will show a "psuedo-readonly" 

605 form with no fields editable, but with a submit button so user 

606 must confirm, before deletion actually occurs. 

607 

608 Subclass normally should not override this method, but rather 

609 one of the related methods which are called (in)directly by 

610 this one: 

611 

612 * :meth:`make_model_form()` 

613 * :meth:`configure_form()` 

614 * :meth:`delete_save_form()` 

615 * :meth:`delete_instance()` 

616 """ 

617 self.deleting = True 

618 instance = self.get_instance() 

619 

620 if not self.is_deletable(instance): 

621 return self.redirect(self.get_action_url('view', instance)) 

622 

623 # nb. this form proper is not readonly.. 

624 form = self.make_model_form(instance, 

625 cancel_url_fallback=self.get_action_url('view', instance), 

626 button_label_submit="DELETE Forever", 

627 button_icon_submit='trash', 

628 button_type_submit='is-danger') 

629 # ..but *all* fields are readonly 

630 form.readonly_fields = set(form.fields) 

631 

632 # nb. validate() often returns empty dict here 

633 if form.validate() is not False: 

634 self.delete_save_form(form) 

635 return self.redirect(self.get_index_url()) 

636 

637 context = { 

638 'instance': instance, 

639 'form': form, 

640 } 

641 return self.render_to_response('delete', context) 

642 

643 def delete_save_form(self, form): 

644 """ 

645 Perform the delete operation(s) based on the given form data. 

646 

647 Default logic simply calls :meth:`delete_instance()` on the 

648 form's :attr:`~wuttaweb.forms.base.Form.model_instance`. 

649 

650 This method is called by :meth:`delete()` after it has 

651 validated the form. 

652 """ 

653 obj = form.model_instance 

654 self.delete_instance(obj) 

655 

656 def delete_instance(self, obj): 

657 """ 

658 Delete the given model instance. 

659 

660 As of yet there is no default logic for this method; it will 

661 raise ``NotImplementedError``. Subclass should override if 

662 needed. 

663 

664 This method is called by :meth:`delete_save_form()`. 

665 """ 

666 session = self.app.get_session(obj) 

667 session.delete(obj) 

668 

669 def delete_bulk(self): 

670 """ 

671 View to delete all records in the current :meth:`index()` grid 

672 data set, i.e. those matching current query. 

673 

674 This usually corresponds to a URL like 

675 ``/widgets/delete-bulk``. 

676 

677 By default, this view is included only if 

678 :attr:`deletable_bulk` is true. 

679 

680 This view requires POST method. When it is finished deleting, 

681 user is redirected back to :meth:`index()` view. 

682 

683 Subclass normally should not override this method, but rather 

684 one of the related methods which are called (in)directly by 

685 this one: 

686 

687 * :meth:`delete_bulk_action()` 

688 """ 

689 

690 # get current data set from grid 

691 # nb. this must *not* be paginated, we need it all 

692 grid = self.make_model_grid(paginated=False) 

693 data = grid.get_visible_data() 

694 

695 if self.deletable_bulk_quick: 

696 

697 # delete it all and go back to listing 

698 self.delete_bulk_action(data) 

699 return self.redirect(self.get_index_url()) 

700 

701 else: 

702 

703 # start thread for delete; show progress page 

704 route_prefix = self.get_route_prefix() 

705 key = f'{route_prefix}.delete_bulk' 

706 progress = self.make_progress(key, success_url=self.get_index_url()) 

707 thread = threading.Thread(target=self.delete_bulk_thread, 

708 args=(data,), kwargs={'progress': progress}) 

709 thread.start() 

710 return self.render_progress(progress) 

711 

712 def delete_bulk_thread(self, query, success_url=None, progress=None): 

713 """ """ 

714 model_title_plural = self.get_model_title_plural() 

715 

716 # nb. use new session, separate from web transaction 

717 session = self.app.make_session() 

718 records = query.with_session(session).all() 

719 

720 try: 

721 self.delete_bulk_action(records, progress=progress) 

722 

723 except Exception as error: 

724 session.rollback() 

725 log.warning("failed to delete %s results for %s", 

726 len(records), model_title_plural, 

727 exc_info=True) 

728 if progress: 

729 progress.handle_error(error) 

730 

731 else: 

732 session.commit() 

733 if progress: 

734 progress.handle_success() 

735 

736 finally: 

737 session.close() 

738 

739 def delete_bulk_action(self, data, progress=None): 

740 """ 

741 This method performs the actual bulk deletion, for the given 

742 data set. This is called via :meth:`delete_bulk()`. 

743 

744 Default logic will call :meth:`is_deletable()` for every data 

745 record, and if that returns true then it calls 

746 :meth:`delete_instance()`. A progress indicator will be 

747 updated if one is provided. 

748 

749 Subclass should override if needed. 

750 """ 

751 model_title_plural = self.get_model_title_plural() 

752 

753 def delete(obj, i): 

754 if self.is_deletable(obj): 

755 self.delete_instance(obj) 

756 

757 self.app.progress_loop(delete, data, progress, 

758 message=f"Deleting {model_title_plural}") 

759 

760 def delete_bulk_make_button(self): 

761 """ """ 

762 route_prefix = self.get_route_prefix() 

763 

764 label = HTML.literal( 

765 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}') 

766 button = self.make_button(label, 

767 variant='is-danger', 

768 icon_left='trash', 

769 **{'@click': 'deleteResultsSubmit()', 

770 ':disabled': 'deleteResultsDisabled'}) 

771 

772 form = HTML.tag('form', 

773 method='post', 

774 action=self.request.route_url(f'{route_prefix}.delete_bulk'), 

775 ref='deleteResultsForm', 

776 class_='control', 

777 c=[ 

778 render_csrf_token(self.request), 

779 button, 

780 ]) 

781 return form 

782 

783 ############################## 

784 # autocomplete methods 

785 ############################## 

786 

787 def autocomplete(self): 

788 """ 

789 View which accepts a single ``term`` param, and returns a JSON 

790 list of autocomplete results to match. 

791 

792 By default, this view is included only if 

793 :attr:`has_autocomplete` is true. It usually maps to a URL 

794 like ``/widgets/autocomplete``. 

795 

796 Subclass generally does not need to override this method, but 

797 rather should override the others which this calls: 

798 

799 * :meth:`autocomplete_data()` 

800 * :meth:`autocomplete_normalize()` 

801 """ 

802 term = self.request.GET.get('term', '') 

803 if not term: 

804 return [] 

805 

806 data = self.autocomplete_data(term) 

807 if not data: 

808 return [] 

809 

810 max_results = 100 # TODO 

811 

812 results = [] 

813 for obj in data[:max_results]: 

814 normal = self.autocomplete_normalize(obj) 

815 if normal: 

816 results.append(normal) 

817 

818 return results 

819 

820 def autocomplete_data(self, term): 

821 """ 

822 Should return the data/query for the "matching" model records, 

823 based on autocomplete search term. This is called by 

824 :meth:`autocomplete()`. 

825 

826 Subclass must override this; default logic returns no data. 

827 

828 :param term: String search term as-is from user, e.g. "foo bar". 

829 

830 :returns: List of data records, or SQLAlchemy query. 

831 """ 

832 

833 def autocomplete_normalize(self, obj): 

834 """ 

835 Should return a "normalized" version of the given model 

836 record, suitable for autocomplete JSON results. This is 

837 called by :meth:`autocomplete()`. 

838 

839 Subclass may need to override this; default logic is 

840 simplistic but will work for basic models. It returns the 

841 "autocomplete results" dict for the object:: 

842 

843 { 

844 'value': obj.uuid, 

845 'label': str(obj), 

846 } 

847 

848 The 2 keys shown are required; any other keys will be ignored 

849 by the view logic but may be useful on the frontend widget. 

850 

851 :param obj: Model record/instance. 

852 

853 :returns: Dict of "autocomplete results" format, as shown 

854 above. 

855 """ 

856 return { 

857 'value': obj.uuid, 

858 'label': str(obj), 

859 } 

860 

861 ############################## 

862 # download methods 

863 ############################## 

864 

865 def download(self): 

866 """ 

867 View to download a file associated with a model record. 

868 

869 This usually corresponds to a URL like 

870 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID 

871 for the record. 

872 

873 By default, this view is included only if :attr:`downloadable` 

874 is true. 

875 

876 This method will (try to) locate the file on disk, and return 

877 it as a file download response to the client. 

878 

879 The GET request for this view may contain a ``filename`` query 

880 string parameter, which can be used to locate one of various 

881 files associated with the model record. This filename is 

882 passed to :meth:`download_path()` for locating the file. 

883 

884 For instance: ``/widgets/XXX/download?filename=widget-specs.txt`` 

885 

886 Subclass normally should not override this method, but rather 

887 one of the related methods which are called (in)directly by 

888 this one: 

889 

890 * :meth:`download_path()` 

891 """ 

892 obj = self.get_instance() 

893 filename = self.request.GET.get('filename', None) 

894 

895 path = self.download_path(obj, filename) 

896 if not path or not os.path.exists(path): 

897 return self.notfound() 

898 

899 return self.file_response(path) 

900 

901 def download_path(self, obj, filename): 

902 """ 

903 Should return absolute path on disk, for the given object and 

904 filename. Result will be used to return a file response to 

905 client. This is called by :meth:`download()`. 

906 

907 Default logic always returns ``None``; subclass must override. 

908 

909 :param obj: Refefence to the model instance. 

910 

911 :param filename: Name of file for which to retrieve the path. 

912 

913 :returns: Path to file, or ``None`` if not found. 

914 

915 Note that ``filename`` may be ``None`` in which case the "default" 

916 file path should be returned, if applicable. 

917 

918 If this method returns ``None`` (as it does by default) then 

919 the :meth:`download()` view will return a 404 not found 

920 response. 

921 """ 

922 

923 ############################## 

924 # execute methods 

925 ############################## 

926 

927 def execute(self): 

928 """ 

929 View to "execute" a model record. Requires a POST request. 

930 

931 This usually corresponds to a URL like 

932 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID 

933 for the record. 

934 

935 By default, this view is included only if :attr:`executable` is 

936 true. 

937 

938 Probably this is a "rare" view to implement for a model. But 

939 there are two notable use cases so far, namely: 

940 

941 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`) 

942 * batches (not yet implemented; 

943 cf. :doc:`rattail-manual:data/batch/index` in Rattail 

944 Manual) 

945 

946 The general idea is to take some "irrevocable" action 

947 associated with the model record. In the case of upgrades, it 

948 is to run the upgrade script. For batches it is to "push 

949 live" the data held within the batch. 

950 

951 Subclass normally should not override this method, but rather 

952 one of the related methods which are called (in)directly by 

953 this one: 

954 

955 * :meth:`execute_instance()` 

956 """ 

957 route_prefix = self.get_route_prefix() 

958 model_title = self.get_model_title() 

959 obj = self.get_instance() 

960 

961 # make the progress tracker 

962 progress = self.make_progress(f'{route_prefix}.execute', 

963 success_msg=f"{model_title} was executed.", 

964 success_url=self.get_action_url('view', obj)) 

965 

966 # start thread for execute; show progress page 

967 key = self.request.matchdict 

968 thread = threading.Thread(target=self.execute_thread, 

969 args=(key, self.request.user.uuid), 

970 kwargs={'progress': progress}) 

971 thread.start() 

972 return self.render_progress(progress, context={ 

973 'instance': obj, 

974 }, template=self.execute_progress_template) 

975 

976 def execute_instance(self, obj, user, progress=None): 

977 """ 

978 Perform the actual "execution" logic for a model record. 

979 Called by :meth:`execute()`. 

980 

981 This method does nothing by default; subclass must override. 

982 

983 :param obj: Reference to the model instance. 

984 

985 :param user: Reference to the 

986 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who 

987 is doing the execute. 

988 

989 :param progress: Optional progress indicator factory. 

990 """ 

991 

992 def execute_thread(self, key, user_uuid, progress=None): 

993 """ """ 

994 model = self.app.model 

995 model_title = self.get_model_title() 

996 

997 # nb. use new session, separate from web transaction 

998 session = self.app.make_session() 

999 

1000 # fetch model instance and user for this session 

1001 obj = self.get_instance(session=session, matchdict=key) 

1002 user = session.get(model.User, user_uuid) 

1003 

1004 try: 

1005 self.execute_instance(obj, user, progress=progress) 

1006 

1007 except Exception as error: 

1008 session.rollback() 

1009 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True) 

1010 if progress: 

1011 progress.handle_error(error) 

1012 

1013 else: 

1014 session.commit() 

1015 if progress: 

1016 progress.handle_success() 

1017 

1018 finally: 

1019 session.close() 

1020 

1021 ############################## 

1022 # configure methods 

1023 ############################## 

1024 

1025 def configure(self, session=None): 

1026 """ 

1027 View for configuring aspects of the app which are pertinent to 

1028 this master view and/or model. 

1029 

1030 By default, this view is included only if :attr:`configurable` 

1031 is true. It usually maps to a URL like ``/widgets/configure``. 

1032 

1033 The expected workflow is as follows: 

1034 

1035 * user navigates to Configure page 

1036 * user modifies settings and clicks Save 

1037 * this view then *deletes* all "known" settings 

1038 * then it saves user-submitted settings 

1039 

1040 That is unless ``remove_settings`` is requested, in which case 

1041 settings are deleted but then none are saved. The "known" 

1042 settings by default include only the "simple" settings. 

1043 

1044 As a general rule, a particular setting should be configurable 

1045 by (at most) one master view. Some settings may never be 

1046 exposed at all. But when exposing a setting, careful thought 

1047 should be given to where it logically/best belongs. 

1048 

1049 Some settings are "simple" and a master view subclass need 

1050 only provide their basic definitions via 

1051 :meth:`configure_get_simple_settings()`. If complex settings 

1052 are needed, subclass must override one or more other methods 

1053 to achieve the aim(s). 

1054 

1055 See also related methods, used by this one: 

1056 

1057 * :meth:`configure_get_simple_settings()` 

1058 * :meth:`configure_get_context()` 

1059 * :meth:`configure_gather_settings()` 

1060 * :meth:`configure_remove_settings()` 

1061 * :meth:`configure_save_settings()` 

1062 """ 

1063 self.configuring = True 

1064 config_title = self.get_config_title() 

1065 

1066 # was form submitted? 

1067 if self.request.method == 'POST': 

1068 

1069 # maybe just remove settings 

1070 if self.request.POST.get('remove_settings'): 

1071 self.configure_remove_settings(session=session) 

1072 self.request.session.flash(f"All settings for {config_title} have been removed.", 

1073 'warning') 

1074 

1075 # reload configure page 

1076 return self.redirect(self.request.current_route_url()) 

1077 

1078 # gather/save settings 

1079 data = get_form_data(self.request) 

1080 settings = self.configure_gather_settings(data) 

1081 self.configure_remove_settings(session=session) 

1082 self.configure_save_settings(settings, session=session) 

1083 self.request.session.flash("Settings have been saved.") 

1084 

1085 # reload configure page 

1086 return self.redirect(self.request.current_route_url()) 

1087 

1088 # render configure page 

1089 context = self.configure_get_context() 

1090 return self.render_to_response('configure', context) 

1091 

1092 def configure_get_context( 

1093 self, 

1094 simple_settings=None, 

1095 ): 

1096 """ 

1097 Returns the full context dict, for rendering the 

1098 :meth:`configure()` page template. 

1099 

1100 Default context will include ``simple_settings`` (normalized 

1101 to just name/value). 

1102 

1103 You may need to override this method, to add additional 

1104 "complex" settings etc. 

1105 

1106 :param simple_settings: Optional list of simple settings, if 

1107 already initialized. Otherwise it is retrieved via 

1108 :meth:`configure_get_simple_settings()`. 

1109 

1110 :returns: Context dict for the page template. 

1111 """ 

1112 context = {} 

1113 

1114 # simple settings 

1115 if simple_settings is None: 

1116 simple_settings = self.configure_get_simple_settings() 

1117 if simple_settings: 

1118 

1119 # we got some, so "normalize" each definition to name/value 

1120 normalized = {} 

1121 for simple in simple_settings: 

1122 

1123 # name 

1124 name = simple['name'] 

1125 

1126 # value 

1127 if 'value' in simple: 

1128 value = simple['value'] 

1129 elif simple.get('type') is bool: 

1130 value = self.config.get_bool(name, default=simple.get('default', False)) 

1131 else: 

1132 value = self.config.get(name) 

1133 

1134 normalized[name] = value 

1135 

1136 # add to template context 

1137 context['simple_settings'] = normalized 

1138 

1139 return context 

1140 

1141 def configure_get_simple_settings(self): 

1142 """ 

1143 This should return a list of "simple" setting definitions for 

1144 the :meth:`configure()` view, which can be handled in a more 

1145 automatic way. (This is as opposed to some settings which are 

1146 more complex and must be handled manually; those should not be 

1147 part of this method's return value.) 

1148 

1149 Basically a "simple" setting is one which can be represented 

1150 by a single field/widget on the Configure page. 

1151 

1152 The setting definitions returned must each be a dict of 

1153 "attributes" for the setting. For instance a *very* simple 

1154 setting might be:: 

1155 

1156 {'name': 'wutta.app_title'} 

1157 

1158 The ``name`` is required, everything else is optional. Here 

1159 is a more complete example:: 

1160 

1161 { 

1162 'name': 'wutta.production', 

1163 'type': bool, 

1164 'default': False, 

1165 'save_if_empty': False, 

1166 } 

1167 

1168 Note that if specified, the ``default`` should be of the same 

1169 data type as defined for the setting (``bool`` in the above 

1170 example). The default ``type`` is ``str``. 

1171 

1172 Normally if a setting's value is effectively null, the setting 

1173 is removed instead of keeping it in the DB. This behavior can 

1174 be changed per-setting via the ``save_if_empty`` flag. 

1175 

1176 :returns: List of setting definition dicts as described above. 

1177 Note that their order does not matter since the template 

1178 must explicitly define field layout etc. 

1179 """ 

1180 

1181 def configure_gather_settings( 

1182 self, 

1183 data, 

1184 simple_settings=None, 

1185 ): 

1186 """ 

1187 Collect the full set of "normalized" settings from user 

1188 request, so that :meth:`configure()` can save them. 

1189 

1190 Settings are gathered from the given request (e.g. POST) 

1191 ``data``, but also taking into account what we know based on 

1192 the simple setting definitions. 

1193 

1194 Subclass may need to override this method if complex settings 

1195 are required. 

1196 

1197 :param data: Form data submitted via POST request. 

1198 

1199 :param simple_settings: Optional list of simple settings, if 

1200 already initialized. Otherwise it is retrieved via 

1201 :meth:`configure_get_simple_settings()`. 

1202 

1203 This method must return a list of normalized settings, similar 

1204 in spirit to the definition syntax used in 

1205 :meth:`configure_get_simple_settings()`. However the format 

1206 returned here is minimal and contains just name/value:: 

1207 

1208 { 

1209 'name': 'wutta.app_title', 

1210 'value': 'Wutta Wutta', 

1211 } 

1212 

1213 Note that the ``value`` will always be a string. 

1214 

1215 Also note, whereas it's possible ``data`` will not contain all 

1216 known settings, the return value *should* (potentially) 

1217 contain all of them. 

1218 

1219 The one exception is when a simple setting has null value, by 

1220 default it will not be included in the result (hence, not 

1221 saved to DB) unless the setting definition has the 

1222 ``save_if_empty`` flag set. 

1223 """ 

1224 settings = [] 

1225 

1226 # simple settings 

1227 if simple_settings is None: 

1228 simple_settings = self.configure_get_simple_settings() 

1229 if simple_settings: 

1230 

1231 # we got some, so "normalize" each definition to name/value 

1232 for simple in simple_settings: 

1233 name = simple['name'] 

1234 

1235 if name in data: 

1236 value = data[name] 

1237 else: 

1238 value = simple.get('default') 

1239 

1240 if simple.get('type') is bool: 

1241 value = str(bool(value)).lower() 

1242 elif simple.get('type') is int: 

1243 value = str(int(value or '0')) 

1244 elif value is None: 

1245 value = '' 

1246 else: 

1247 value = str(value) 

1248 

1249 # only want to save this setting if we received a 

1250 # value, or if empty values are okay to save 

1251 if value or simple.get('save_if_empty'): 

1252 settings.append({'name': name, 

1253 'value': value}) 

1254 

1255 return settings 

1256 

1257 def configure_remove_settings( 

1258 self, 

1259 simple_settings=None, 

1260 session=None, 

1261 ): 

1262 """ 

1263 Remove all "known" settings from the DB; this is called by 

1264 :meth:`configure()`. 

1265 

1266 The point of this method is to ensure *all* "known" settings 

1267 which are managed by this master view, are purged from the DB. 

1268 

1269 The default logic can handle this automatically for simple 

1270 settings; subclass must override for any complex settings. 

1271 

1272 :param simple_settings: Optional list of simple settings, if 

1273 already initialized. Otherwise it is retrieved via 

1274 :meth:`configure_get_simple_settings()`. 

1275 """ 

1276 names = [] 

1277 

1278 # simple settings 

1279 if simple_settings is None: 

1280 simple_settings = self.configure_get_simple_settings() 

1281 if simple_settings: 

1282 names.extend([simple['name'] 

1283 for simple in simple_settings]) 

1284 

1285 if names: 

1286 # nb. must avoid self.Session here in case that does not 

1287 # point to our primary app DB 

1288 session = session or self.Session() 

1289 for name in names: 

1290 self.app.delete_setting(session, name) 

1291 

1292 def configure_save_settings(self, settings, session=None): 

1293 """ 

1294 Save the given settings to the DB; this is called by 

1295 :meth:`configure()`. 

1296 

1297 This method expects a list of name/value dicts and will simply 

1298 save each to the DB, with no "conversion" logic. 

1299 

1300 :param settings: List of normalized setting definitions, as 

1301 returned by :meth:`configure_gather_settings()`. 

1302 """ 

1303 # nb. must avoid self.Session here in case that does not point 

1304 # to our primary app DB 

1305 session = session or self.Session() 

1306 for setting in settings: 

1307 self.app.save_setting(session, setting['name'], setting['value'], 

1308 force_create=True) 

1309 

1310 ############################## 

1311 # grid rendering methods 

1312 ############################## 

1313 

1314 def grid_render_bool(self, record, key, value): 

1315 """ 

1316 Custom grid value renderer for "boolean" fields. 

1317 

1318 This converts a bool value to "Yes" or "No" - unless the value 

1319 is ``None`` in which case this renders empty string. 

1320 To use this feature for your grid:: 

1321 

1322 grid.set_renderer('my_bool_field', self.grid_render_bool) 

1323 """ 

1324 if value is None: 

1325 return 

1326 

1327 return "Yes" if value else "No" 

1328 

1329 def grid_render_currency(self, record, key, value, scale=2): 

1330 """ 

1331 Custom grid value renderer for "currency" fields. 

1332 

1333 This expects float or decimal values, and will round the 

1334 decimal as appropriate, and add the currency symbol. 

1335 

1336 :param scale: Number of decimal digits to be displayed; 

1337 default is 2 places. 

1338 

1339 To use this feature for your grid:: 

1340 

1341 grid.set_renderer('my_currency_field', self.grid_render_currency) 

1342 

1343 # you can also override scale 

1344 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4) 

1345 """ 

1346 

1347 # nb. get new value since the one provided will just be a 

1348 # (json-safe) *string* if the original type was Decimal 

1349 value = record[key] 

1350 

1351 if value is None: 

1352 return 

1353 

1354 if value < 0: 

1355 fmt = f"(${{:0,.{scale}f}})" 

1356 return fmt.format(0 - value) 

1357 

1358 fmt = f"${{:0,.{scale}f}}" 

1359 return fmt.format(value) 

1360 

1361 def grid_render_datetime(self, record, key, value, fmt=None): 

1362 """ 

1363 Custom grid value renderer for 

1364 :class:`~python:datetime.datetime` fields. 

1365 

1366 :param fmt: Optional format string to use instead of the 

1367 default: ``'%Y-%m-%d %I:%M:%S %p'`` 

1368 

1369 To use this feature for your grid:: 

1370 

1371 grid.set_renderer('my_datetime_field', self.grid_render_datetime) 

1372 

1373 # you can also override format 

1374 grid.set_renderer('my_datetime_field', self.grid_render_datetime, 

1375 fmt='%Y-%m-%d %H:%M:%S') 

1376 """ 

1377 # nb. get new value since the one provided will just be a 

1378 # (json-safe) *string* if the original type was datetime 

1379 value = record[key] 

1380 

1381 if value is None: 

1382 return 

1383 

1384 return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p') 

1385 

1386 def grid_render_enum(self, record, key, value, enum=None): 

1387 """ 

1388 Custom grid value renderer for "enum" fields. 

1389 

1390 :param enum: Enum class for the field. This should be an 

1391 instance of :class:`~python:enum.Enum`. 

1392 

1393 To use this feature for your grid:: 

1394 

1395 from enum import Enum 

1396 

1397 class MyEnum(Enum): 

1398 ONE = 1 

1399 TWO = 2 

1400 THREE = 3 

1401 

1402 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum) 

1403 """ 

1404 if enum: 

1405 original = record[key] 

1406 if original: 

1407 return original.name 

1408 

1409 return value 

1410 

1411 def grid_render_notes(self, record, key, value, maxlen=100): 

1412 """ 

1413 Custom grid value renderer for "notes" fields. 

1414 

1415 If the given text ``value`` is shorter than ``maxlen`` 

1416 characters, it is returned as-is. 

1417 

1418 But if it is longer, then it is truncated and an ellispsis is 

1419 added. The resulting ``<span>`` tag is also given a ``title`` 

1420 attribute with the original (full) text, so that appears on 

1421 mouse hover. 

1422 

1423 To use this feature for your grid:: 

1424 

1425 grid.set_renderer('my_notes_field', self.grid_render_notes) 

1426 

1427 # you can also override maxlen 

1428 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50) 

1429 """ 

1430 if value is None: 

1431 return 

1432 

1433 if len(value) < maxlen: 

1434 return value 

1435 

1436 return HTML.tag('span', title=value, c=f"{value[:maxlen]}...") 

1437 

1438 ############################## 

1439 # support methods 

1440 ############################## 

1441 

1442 def get_class_hierarchy(self, topfirst=True): 

1443 """ 

1444 Convenience to return a list of classes from which the current 

1445 class inherits. 

1446 

1447 This is a wrapper around 

1448 :func:`wuttjamaican.util.get_class_hierarchy()`. 

1449 """ 

1450 return get_class_hierarchy(self.__class__, topfirst=topfirst) 

1451 

1452 def has_perm(self, name): 

1453 """ 

1454 Shortcut to check if current user has the given permission. 

1455 

1456 This will automatically add the :attr:`permission_prefix` to 

1457 ``name`` before passing it on to 

1458 :func:`~wuttaweb.subscribers.request.has_perm()`. 

1459 

1460 For instance within the 

1461 :class:`~wuttaweb.views.users.UserView` these give the same 

1462 result:: 

1463 

1464 self.request.has_perm('users.edit') 

1465 

1466 self.has_perm('edit') 

1467 

1468 So this shortcut only applies to permissions defined for the 

1469 current master view. The first example above must still be 

1470 used to check for "foreign" permissions (i.e. any needing a 

1471 different prefix). 

1472 """ 

1473 permission_prefix = self.get_permission_prefix() 

1474 return self.request.has_perm(f'{permission_prefix}.{name}') 

1475 

1476 def has_any_perm(self, *names): 

1477 """ 

1478 Shortcut to check if current user has any of the given 

1479 permissions. 

1480 

1481 This calls :meth:`has_perm()` until one returns ``True``. If 

1482 none do, returns ``False``. 

1483 """ 

1484 for name in names: 

1485 if self.has_perm(name): 

1486 return True 

1487 return False 

1488 

1489 def make_button( 

1490 self, 

1491 label, 

1492 variant=None, 

1493 primary=False, 

1494 **kwargs, 

1495 ): 

1496 """ 

1497 Make and return a HTML ``<b-button>`` literal. 

1498 

1499 :param label: Text label for the button. 

1500 

1501 :param variant: This is the "Buefy type" (or "Oruga variant") 

1502 for the button. Buefy and Oruga represent this differently 

1503 but this logic expects the Buefy format 

1504 (e.g. ``is-danger``) and *not* the Oruga format 

1505 (e.g. ``danger``), despite the param name matching Oruga's 

1506 terminology. 

1507 

1508 :param type: This param is not advertised in the method 

1509 signature, but if caller specifies ``type`` instead of 

1510 ``variant`` it should work the same. 

1511 

1512 :param primary: If neither ``variant`` nor ``type`` are 

1513 specified, this flag may be used to automatically set the 

1514 Buefy type to ``is-primary``. 

1515 

1516 This is the preferred method where applicable, since it 

1517 avoids the Buefy vs. Oruga confusion, and the 

1518 implementation can change in the future. 

1519 

1520 :param \**kwargs: All remaining kwargs are passed to the 

1521 underlying ``HTML.tag()`` call, so will be rendered as 

1522 attributes on the button tag. 

1523 

1524 :returns: HTML literal for the button element. Will be something 

1525 along the lines of: 

1526 

1527 .. code-block:: 

1528 

1529 <b-button type="is-primary" 

1530 icon-pack="fas" 

1531 icon-left="hand-pointer"> 

1532 Click Me 

1533 </b-button> 

1534 """ 

1535 btn_kw = kwargs 

1536 btn_kw.setdefault('c', label) 

1537 btn_kw.setdefault('icon_pack', 'fas') 

1538 

1539 if 'type' not in btn_kw: 

1540 if variant: 

1541 btn_kw['type'] = variant 

1542 elif primary: 

1543 btn_kw['type'] = 'is-primary' 

1544 

1545 return HTML.tag('b-button', **btn_kw) 

1546 

1547 def make_progress(self, key, **kwargs): 

1548 """ 

1549 Create and return a 

1550 :class:`~wuttaweb.progress.SessionProgress` instance, with the 

1551 given key. 

1552 

1553 This is normally done just before calling 

1554 :meth:`render_progress()`. 

1555 """ 

1556 return SessionProgress(self.request, key, **kwargs) 

1557 

1558 def render_progress(self, progress, context=None, template=None): 

1559 """ 

1560 Render the progress page, with given template/context. 

1561 

1562 When a view method needs to start a long-running operation, it 

1563 first starts a thread to do the work, and then it renders the 

1564 "progress" page. As the operation continues the progress page 

1565 is updated. When the operation completes (or fails) the user 

1566 is redirected to the final destination. 

1567 

1568 TODO: should document more about how to do this.. 

1569 

1570 :param progress: Progress indicator instance as returned by 

1571 :meth:`make_progress()`. 

1572 

1573 :returns: A :term:`response` with rendered progress page. 

1574 """ 

1575 template = template or '/progress.mako' 

1576 context = context or {} 

1577 context['progress'] = progress 

1578 return render_to_response(template, context, request=self.request) 

1579 

1580 def render_to_response(self, template, context): 

1581 """ 

1582 Locate and render an appropriate template, with the given 

1583 context, and return a :term:`response`. 

1584 

1585 The specified ``template`` should be only the "base name" for 

1586 the template - e.g. ``'index'`` or ``'edit'``. This method 

1587 will then try to locate a suitable template file, based on 

1588 values from :meth:`get_template_prefix()` and 

1589 :meth:`get_fallback_templates()`. 

1590 

1591 In practice this *usually* means two different template paths 

1592 will be attempted, e.g. if ``template`` is ``'edit'`` and 

1593 :attr:`template_prefix` is ``'/widgets'``: 

1594 

1595 * ``/widgets/edit.mako`` 

1596 * ``/master/edit.mako`` 

1597 

1598 The first template found to exist will be used for rendering. 

1599 It then calls 

1600 :func:`pyramid:pyramid.renderers.render_to_response()` and 

1601 returns the result. 

1602 

1603 :param template: Base name for the template. 

1604 

1605 :param context: Data dict to be used as template context. 

1606 

1607 :returns: Response object containing the rendered template. 

1608 """ 

1609 defaults = { 

1610 'master': self, 

1611 'route_prefix': self.get_route_prefix(), 

1612 'index_title': self.get_index_title(), 

1613 'index_url': self.get_index_url(), 

1614 'model_title': self.get_model_title(), 

1615 'config_title': self.get_config_title(), 

1616 } 

1617 

1618 # merge defaults + caller-provided context 

1619 defaults.update(context) 

1620 context = defaults 

1621 

1622 # add crud flags if we have an instance 

1623 if 'instance' in context: 

1624 instance = context['instance'] 

1625 if 'instance_title' not in context: 

1626 context['instance_title'] = self.get_instance_title(instance) 

1627 if 'instance_editable' not in context: 

1628 context['instance_editable'] = self.is_editable(instance) 

1629 if 'instance_deletable' not in context: 

1630 context['instance_deletable'] = self.is_deletable(instance) 

1631 

1632 # first try the template path most specific to this view 

1633 template_prefix = self.get_template_prefix() 

1634 mako_path = f'{template_prefix}/{template}.mako' 

1635 try: 

1636 return render_to_response(mako_path, context, request=self.request) 

1637 except IOError: 

1638 

1639 # failing that, try one or more fallback templates 

1640 for fallback in self.get_fallback_templates(template): 

1641 try: 

1642 return render_to_response(fallback, context, request=self.request) 

1643 except IOError: 

1644 pass 

1645 

1646 # if we made it all the way here, then we found no 

1647 # templates at all, in which case re-attempt the first and 

1648 # let that error raise on up 

1649 return render_to_response(mako_path, context, request=self.request) 

1650 

1651 def get_fallback_templates(self, template): 

1652 """ 

1653 Returns a list of "fallback" template paths which may be 

1654 attempted for rendering a view. This is used within 

1655 :meth:`render_to_response()` if the "first guess" template 

1656 file was not found. 

1657 

1658 :param template: Base name for a template (without prefix), e.g. 

1659 ``'custom'``. 

1660 

1661 :returns: List of full template paths to be tried, based on 

1662 the specified template. For instance if ``template`` is 

1663 ``'custom'`` this will (by default) return:: 

1664 

1665 ['/master/custom.mako'] 

1666 """ 

1667 return [f'/master/{template}.mako'] 

1668 

1669 def get_index_title(self): 

1670 """ 

1671 Returns the main index title for the master view. 

1672 

1673 By default this returns the value from 

1674 :meth:`get_model_title_plural()`. Subclass may override as 

1675 needed. 

1676 """ 

1677 return self.get_model_title_plural() 

1678 

1679 def get_index_url(self, **kwargs): 

1680 """ 

1681 Returns the URL for master's :meth:`index()` view. 

1682 

1683 NB. this returns ``None`` if :attr:`listable` is false. 

1684 """ 

1685 if self.listable: 

1686 route_prefix = self.get_route_prefix() 

1687 return self.request.route_url(route_prefix, **kwargs) 

1688 

1689 def set_labels(self, obj): 

1690 """ 

1691 Set label overrides on a form or grid, based on what is 

1692 defined by the view class and its parent class(es). 

1693 

1694 This is called automatically from :meth:`configure_grid()` and 

1695 :meth:`configure_form()`. 

1696 

1697 This calls :meth:`collect_labels()` to find everything, then 

1698 it assigns the labels using one of (based on ``obj`` type): 

1699 

1700 * :func:`wuttaweb.forms.base.Form.set_label()` 

1701 * :func:`wuttaweb.grids.base.Grid.set_label()` 

1702 

1703 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a 

1704 :class:`~wuttaweb.forms.base.Form` instance. 

1705 """ 

1706 labels = self.collect_labels() 

1707 for key, label in labels.items(): 

1708 obj.set_label(key, label) 

1709 

1710 def collect_labels(self): 

1711 """ 

1712 Collect all labels defined by the view class and/or its parents. 

1713 

1714 A master view can declare labels via class-level attribute, 

1715 like so:: 

1716 

1717 from wuttaweb.views import MasterView 

1718 

1719 class WidgetView(MasterView): 

1720 

1721 labels = { 

1722 'id': "Widget ID", 

1723 'serial_no': "Serial Number", 

1724 } 

1725 

1726 All such labels, defined by any class from which the master 

1727 view inherits, will be returned. However if the same label 

1728 key is defined by multiple classes, the "subclass" always 

1729 wins. 

1730 

1731 Labels defined in this way will apply to both forms and grids. 

1732 See also :meth:`set_labels()`. 

1733 

1734 :returns: Dict of all labels found. 

1735 """ 

1736 labels = {} 

1737 hierarchy = self.get_class_hierarchy() 

1738 for cls in hierarchy: 

1739 if hasattr(cls, 'labels'): 

1740 labels.update(cls.labels) 

1741 return labels 

1742 

1743 def make_model_grid(self, session=None, **kwargs): 

1744 """ 

1745 Create and return a :class:`~wuttaweb.grids.base.Grid` 

1746 instance for use with the :meth:`index()` view. 

1747 

1748 See also related methods, which are called by this one: 

1749 

1750 * :meth:`get_grid_key()` 

1751 * :meth:`get_grid_columns()` 

1752 * :meth:`get_grid_data()` 

1753 * :meth:`configure_grid()` 

1754 """ 

1755 if 'key' not in kwargs: 

1756 kwargs['key'] = self.get_grid_key() 

1757 

1758 if 'model_class' not in kwargs: 

1759 model_class = self.get_model_class() 

1760 if model_class: 

1761 kwargs['model_class'] = model_class 

1762 

1763 if 'columns' not in kwargs: 

1764 kwargs['columns'] = self.get_grid_columns() 

1765 

1766 if 'data' not in kwargs: 

1767 kwargs['data'] = self.get_grid_data(columns=kwargs['columns'], 

1768 session=session) 

1769 

1770 if 'actions' not in kwargs: 

1771 actions = [] 

1772 

1773 # TODO: should split this off into index_get_grid_actions() ? 

1774 

1775 if self.viewable and self.has_perm('view'): 

1776 actions.append(self.make_grid_action('view', icon='eye', 

1777 url=self.get_action_url_view)) 

1778 

1779 if self.editable and self.has_perm('edit'): 

1780 actions.append(self.make_grid_action('edit', icon='edit', 

1781 url=self.get_action_url_edit)) 

1782 

1783 if self.deletable and self.has_perm('delete'): 

1784 actions.append(self.make_grid_action('delete', icon='trash', 

1785 url=self.get_action_url_delete, 

1786 link_class='has-text-danger')) 

1787 

1788 kwargs['actions'] = actions 

1789 

1790 if 'tools' not in kwargs: 

1791 tools = [] 

1792 

1793 if self.deletable_bulk and self.has_perm('delete_bulk'): 

1794 tools.append(('delete-results', self.delete_bulk_make_button())) 

1795 

1796 kwargs['tools'] = tools 

1797 

1798 if hasattr(self, 'grid_row_class'): 

1799 kwargs.setdefault('row_class', self.grid_row_class) 

1800 kwargs.setdefault('filterable', self.filterable) 

1801 kwargs.setdefault('filter_defaults', self.filter_defaults) 

1802 kwargs.setdefault('sortable', self.sortable) 

1803 kwargs.setdefault('sort_multiple', not self.request.use_oruga) 

1804 kwargs.setdefault('sort_on_backend', self.sort_on_backend) 

1805 kwargs.setdefault('sort_defaults', self.sort_defaults) 

1806 kwargs.setdefault('paginated', self.paginated) 

1807 kwargs.setdefault('paginate_on_backend', self.paginate_on_backend) 

1808 

1809 grid = self.make_grid(**kwargs) 

1810 self.configure_grid(grid) 

1811 grid.load_settings() 

1812 return grid 

1813 

1814 def get_grid_columns(self): 

1815 """ 

1816 Returns the default list of grid column names, for the 

1817 :meth:`index()` view. 

1818 

1819 This is called by :meth:`make_model_grid()`; in the resulting 

1820 :class:`~wuttaweb.grids.base.Grid` instance, this becomes 

1821 :attr:`~wuttaweb.grids.base.Grid.columns`. 

1822 

1823 This method may return ``None``, in which case the grid may 

1824 (try to) generate its own default list. 

1825 

1826 Subclass may define :attr:`grid_columns` for simple cases, or 

1827 can override this method if needed. 

1828 

1829 Also note that :meth:`configure_grid()` may be used to further 

1830 modify the final column set, regardless of what this method 

1831 returns. So a common pattern is to declare all "supported" 

1832 columns by setting :attr:`grid_columns` but then optionally 

1833 remove or replace some of those within 

1834 :meth:`configure_grid()`. 

1835 """ 

1836 if hasattr(self, 'grid_columns'): 

1837 return self.grid_columns 

1838 

1839 def get_grid_data(self, columns=None, session=None): 

1840 """ 

1841 Returns the grid data for the :meth:`index()` view. 

1842 

1843 This is called by :meth:`make_model_grid()`; in the resulting 

1844 :class:`~wuttaweb.grids.base.Grid` instance, this becomes 

1845 :attr:`~wuttaweb.grids.base.Grid.data`. 

1846 

1847 Default logic will call :meth:`get_query()` and if successful, 

1848 return the list from ``query.all()``. Otherwise returns an 

1849 empty list. Subclass should override as needed. 

1850 """ 

1851 query = self.get_query(session=session) 

1852 if query: 

1853 return query 

1854 return [] 

1855 

1856 def get_query(self, session=None): 

1857 """ 

1858 Returns the main SQLAlchemy query object for the 

1859 :meth:`index()` view. This is called by 

1860 :meth:`get_grid_data()`. 

1861 

1862 Default logic for this method returns a "plain" query on the 

1863 :attr:`model_class` if that is defined; otherwise ``None``. 

1864 """ 

1865 model_class = self.get_model_class() 

1866 if model_class: 

1867 session = session or self.Session() 

1868 return session.query(model_class) 

1869 

1870 def configure_grid(self, grid): 

1871 """ 

1872 Configure the grid for the :meth:`index()` view. 

1873 

1874 This is called by :meth:`make_model_grid()`. 

1875 

1876 There is no default logic here; subclass should override as 

1877 needed. The ``grid`` param will already be "complete" and 

1878 ready to use as-is, but this method can further modify it 

1879 based on request details etc. 

1880 """ 

1881 if 'uuid' in grid.columns: 

1882 grid.columns.remove('uuid') 

1883 

1884 self.set_labels(grid) 

1885 

1886 # TODO: i thought this was a good idea but if so it 

1887 # needs a try/catch in case of no model class 

1888 # for key in self.get_model_key(): 

1889 # grid.set_link(key) 

1890 

1891 def get_instance(self, session=None, matchdict=None): 

1892 """ 

1893 This should return the appropriate model instance, based on 

1894 the ``matchdict`` of model keys. 

1895 

1896 Normally this is called with no arguments, in which case the 

1897 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and 

1898 will return the "current" model instance based on the request 

1899 (route/params). 

1900 

1901 If a ``matchdict`` is provided then that is used instead, to 

1902 obtain the model keys. In the simple/common example of a 

1903 "native" model in WuttaWeb, this would look like:: 

1904 

1905 keys = {'uuid': '38905440630d11ef9228743af49773a4'} 

1906 obj = self.get_instance(matchdict=keys) 

1907 

1908 Although some models may have different, possibly composite 

1909 key names to use instead. The specific keys this logic is 

1910 expecting are the same as returned by :meth:`get_model_key()`. 

1911 

1912 If this method is unable to locate the instance, it should 

1913 raise a 404 error, 

1914 i.e. :meth:`~wuttaweb.views.base.View.notfound()`. 

1915 

1916 Default implementation of this method should work okay for 

1917 views which define a :attr:`model_class`. For other views 

1918 however it will raise ``NotImplementedError``, so subclass 

1919 may need to define. 

1920 

1921 .. warning:: 

1922 

1923 If you are defining this method for a subclass, please note 

1924 this point regarding the 404 "not found" logic. 

1925 

1926 It is *not* enough to simply *return* this 404 response, 

1927 you must explicitly *raise* the error. For instance:: 

1928 

1929 def get_instance(self, **kwargs): 

1930 

1931 # ..try to locate instance.. 

1932 obj = self.locate_instance_somehow() 

1933 

1934 if not obj: 

1935 

1936 # NB. THIS MAY NOT WORK AS EXPECTED 

1937 #return self.notfound() 

1938 

1939 # nb. should always do this in get_instance() 

1940 raise self.notfound() 

1941 

1942 This lets calling code not have to worry about whether or 

1943 not this method might return ``None``. It can safely 

1944 assume it will get back a model instance, or else a 404 

1945 will kick in and control flow goes elsewhere. 

1946 """ 

1947 model_class = self.get_model_class() 

1948 if model_class: 

1949 session = session or self.Session() 

1950 matchdict = matchdict or self.request.matchdict 

1951 

1952 def filtr(query, model_key): 

1953 key = matchdict[model_key] 

1954 query = query.filter(getattr(self.model_class, model_key) == key) 

1955 return query 

1956 

1957 query = session.query(model_class) 

1958 

1959 for key in self.get_model_key(): 

1960 query = filtr(query, key) 

1961 

1962 try: 

1963 return query.one() 

1964 except orm.exc.NoResultFound: 

1965 pass 

1966 

1967 raise self.notfound() 

1968 

1969 raise NotImplementedError("you must define get_instance() method " 

1970 f" for view class: {self.__class__}") 

1971 

1972 def get_instance_title(self, instance): 

1973 """ 

1974 Return the human-friendly "title" for the instance, to be used 

1975 in the page title when viewing etc. 

1976 

1977 Default logic returns the value from ``str(instance)``; 

1978 subclass may override if needed. 

1979 """ 

1980 return str(instance) 

1981 

1982 def get_action_url(self, action, obj, **kwargs): 

1983 """ 

1984 Generate an "action" URL for the given model instance. 

1985 

1986 This is a shortcut which generates a route name based on 

1987 :meth:`get_route_prefix()` and the ``action`` param. 

1988 

1989 It returns the URL based on generated route name and object's 

1990 model key values. 

1991 

1992 :param action: String name for the action, which corresponds 

1993 to part of some named route, e.g. ``'view'`` or ``'edit'``. 

1994 

1995 :param obj: Model instance object. 

1996 """ 

1997 route_prefix = self.get_route_prefix() 

1998 kw = dict([(key, obj[key]) 

1999 for key in self.get_model_key()]) 

2000 kw.update(kwargs) 

2001 return self.request.route_url(f'{route_prefix}.{action}', **kw) 

2002 

2003 def get_action_url_view(self, obj, i): 

2004 """ 

2005 Returns the "view" grid action URL for the given object. 

2006 

2007 Most typically this is like ``/widgets/XXX`` where ``XXX`` 

2008 represents the object's key/ID. 

2009 

2010 Calls :meth:`get_action_url()` under the hood. 

2011 """ 

2012 return self.get_action_url('view', obj) 

2013 

2014 def get_action_url_edit(self, obj, i): 

2015 """ 

2016 Returns the "edit" grid action URL for the given object, if 

2017 applicable. 

2018 

2019 Most typically this is like ``/widgets/XXX/edit`` where 

2020 ``XXX`` represents the object's key/ID. 

2021 

2022 This first calls :meth:`is_editable()` and if that is false, 

2023 this method will return ``None``. 

2024 

2025 Calls :meth:`get_action_url()` to generate the true URL. 

2026 """ 

2027 if self.is_editable(obj): 

2028 return self.get_action_url('edit', obj) 

2029 

2030 def get_action_url_delete(self, obj, i): 

2031 """ 

2032 Returns the "delete" grid action URL for the given object, if 

2033 applicable. 

2034 

2035 Most typically this is like ``/widgets/XXX/delete`` where 

2036 ``XXX`` represents the object's key/ID. 

2037 

2038 This first calls :meth:`is_deletable()` and if that is false, 

2039 this method will return ``None``. 

2040 

2041 Calls :meth:`get_action_url()` to generate the true URL. 

2042 """ 

2043 if self.is_deletable(obj): 

2044 return self.get_action_url('delete', obj) 

2045 

2046 def is_editable(self, obj): 

2047 """ 

2048 Returns a boolean indicating whether "edit" should be allowed 

2049 for the given model instance (and for current user). 

2050 

2051 By default this always return ``True``; subclass can override 

2052 if needed. 

2053 

2054 Note that the use of this method implies :attr:`editable` is 

2055 true, so the method does not need to check that flag. 

2056 """ 

2057 return True 

2058 

2059 def is_deletable(self, obj): 

2060 """ 

2061 Returns a boolean indicating whether "delete" should be 

2062 allowed for the given model instance (and for current user). 

2063 

2064 By default this always return ``True``; subclass can override 

2065 if needed. 

2066 

2067 Note that the use of this method implies :attr:`deletable` is 

2068 true, so the method does not need to check that flag. 

2069 """ 

2070 return True 

2071 

2072 def make_model_form(self, model_instance=None, **kwargs): 

2073 """ 

2074 Create and return a :class:`~wuttaweb.forms.base.Form` 

2075 for the view model. 

2076 

2077 Note that this method is called for multiple "CRUD" views, 

2078 e.g.: 

2079 

2080 * :meth:`view()` 

2081 * :meth:`edit()` 

2082 

2083 See also related methods, which are called by this one: 

2084 

2085 * :meth:`get_form_fields()` 

2086 * :meth:`configure_form()` 

2087 """ 

2088 if 'model_class' not in kwargs: 

2089 model_class = self.get_model_class() 

2090 if model_class: 

2091 kwargs['model_class'] = model_class 

2092 

2093 kwargs['model_instance'] = model_instance 

2094 

2095 if not kwargs.get('fields'): 

2096 fields = self.get_form_fields() 

2097 if fields: 

2098 kwargs['fields'] = fields 

2099 

2100 form = self.make_form(**kwargs) 

2101 self.configure_form(form) 

2102 return form 

2103 

2104 def get_form_fields(self): 

2105 """ 

2106 Returns the initial list of field names for the model form. 

2107 

2108 This is called by :meth:`make_model_form()`; in the resulting 

2109 :class:`~wuttaweb.forms.base.Form` instance, this becomes 

2110 :attr:`~wuttaweb.forms.base.Form.fields`. 

2111 

2112 This method may return ``None``, in which case the form may 

2113 (try to) generate its own default list. 

2114 

2115 Subclass may define :attr:`form_fields` for simple cases, or 

2116 can override this method if needed. 

2117 

2118 Note that :meth:`configure_form()` may be used to further 

2119 modify the final field list, regardless of what this method 

2120 returns. So a common pattern is to declare all "supported" 

2121 fields by setting :attr:`form_fields` but then optionally 

2122 remove or replace some in :meth:`configure_form()`. 

2123 """ 

2124 if hasattr(self, 'form_fields'): 

2125 return self.form_fields 

2126 

2127 def configure_form(self, form): 

2128 """ 

2129 Configure the given model form, as needed. 

2130 

2131 This is called by :meth:`make_model_form()` - for multiple 

2132 CRUD views (create, view, edit, delete, possibly others). 

2133 

2134 The default logic here does just one thing: when "editing" 

2135 (i.e. in :meth:`edit()` view) then all fields which are part 

2136 of the :attr:`model_key` will be marked via 

2137 :meth:`set_readonly()` so the user cannot change primary key 

2138 values for a record. 

2139 

2140 Subclass may override as needed. The ``form`` param will 

2141 already be "complete" and ready to use as-is, but this method 

2142 can further modify it based on request details etc. 

2143 """ 

2144 form.remove('uuid') 

2145 

2146 self.set_labels(form) 

2147 

2148 if self.editing: 

2149 for key in self.get_model_key(): 

2150 form.set_readonly(key) 

2151 

2152 def objectify(self, form): 

2153 """ 

2154 Must return a "model instance" object which reflects the 

2155 validated form data. 

2156 

2157 In simple cases this may just return the 

2158 :attr:`~wuttaweb.forms.base.Form.validated` data dict. 

2159 

2160 When dealing with SQLAlchemy models it would return a proper 

2161 mapped instance, creating it if necessary. 

2162 

2163 :param form: Reference to the *already validated* 

2164 :class:`~wuttaweb.forms.base.Form` object. See the form's 

2165 :attr:`~wuttaweb.forms.base.Form.validated` attribute for 

2166 the data. 

2167 

2168 See also :meth:`edit_save_form()` which calls this method. 

2169 """ 

2170 

2171 # use ColanderAlchemy magic if possible 

2172 schema = form.get_schema() 

2173 if hasattr(schema, 'objectify'): 

2174 # this returns a model instance 

2175 return schema.objectify(form.validated, 

2176 context=form.model_instance) 

2177 

2178 # otherwise return data dict as-is 

2179 return form.validated 

2180 

2181 def persist(self, obj, session=None): 

2182 """ 

2183 If applicable, this method should persist ("save") the given 

2184 object's data (e.g. to DB), creating or updating it as needed. 

2185 

2186 This is part of the "submit form" workflow; ``obj`` should be 

2187 a model instance which already reflects the validated form 

2188 data. 

2189 

2190 Note that there is no default logic here, subclass must 

2191 override if needed. 

2192 

2193 :param obj: Model instance object as produced by 

2194 :meth:`objectify()`. 

2195 

2196 See also :meth:`edit_save_form()` which calls this method. 

2197 """ 

2198 model = self.app.model 

2199 model_class = self.get_model_class() 

2200 if model_class and issubclass(model_class, model.Base): 

2201 

2202 # add sqlalchemy model to session 

2203 session = session or self.Session() 

2204 session.add(obj) 

2205 

2206 ############################## 

2207 # class methods 

2208 ############################## 

2209 

2210 @classmethod 

2211 def get_model_class(cls): 

2212 """ 

2213 Returns the model class for the view (if defined). 

2214 

2215 A model class will *usually* be a SQLAlchemy mapped class, 

2216 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 

2217 

2218 There is no default value here, but a subclass may override by 

2219 assigning :attr:`model_class`. 

2220 

2221 Note that the model class is not *required* - however if you 

2222 do not set the :attr:`model_class`, then you *must* set the 

2223 :attr:`model_name`. 

2224 """ 

2225 if hasattr(cls, 'model_class'): 

2226 return cls.model_class 

2227 

2228 @classmethod 

2229 def get_model_name(cls): 

2230 """ 

2231 Returns the model name for the view. 

2232 

2233 A model name should generally be in the format of a Python 

2234 class name, e.g. ``'WuttaWidget'``. (Note this is 

2235 *singular*, not plural.) 

2236 

2237 The default logic will call :meth:`get_model_class()` and 

2238 return that class name as-is. A subclass may override by 

2239 assigning :attr:`model_name`. 

2240 """ 

2241 if hasattr(cls, 'model_name'): 

2242 return cls.model_name 

2243 

2244 return cls.get_model_class().__name__ 

2245 

2246 @classmethod 

2247 def get_model_name_normalized(cls): 

2248 """ 

2249 Returns the "normalized" model name for the view. 

2250 

2251 A normalized model name should generally be in the format of a 

2252 Python variable name, e.g. ``'wutta_widget'``. (Note this is 

2253 *singular*, not plural.) 

2254 

2255 The default logic will call :meth:`get_model_name()` and 

2256 simply lower-case the result. A subclass may override by 

2257 assigning :attr:`model_name_normalized`. 

2258 """ 

2259 if hasattr(cls, 'model_name_normalized'): 

2260 return cls.model_name_normalized 

2261 

2262 return cls.get_model_name().lower() 

2263 

2264 @classmethod 

2265 def get_model_title(cls): 

2266 """ 

2267 Returns the "humanized" (singular) model title for the view. 

2268 

2269 The model title will be displayed to the user, so should have 

2270 proper grammar and capitalization, e.g. ``"Wutta Widget"``. 

2271 (Note this is *singular*, not plural.) 

2272 

2273 The default logic will call :meth:`get_model_name()` and use 

2274 the result as-is. A subclass may override by assigning 

2275 :attr:`model_title`. 

2276 """ 

2277 if hasattr(cls, 'model_title'): 

2278 return cls.model_title 

2279 

2280 return cls.get_model_name() 

2281 

2282 @classmethod 

2283 def get_model_title_plural(cls): 

2284 """ 

2285 Returns the "humanized" (plural) model title for the view. 

2286 

2287 The model title will be displayed to the user, so should have 

2288 proper grammar and capitalization, e.g. ``"Wutta Widgets"``. 

2289 (Note this is *plural*, not singular.) 

2290 

2291 The default logic will call :meth:`get_model_title()` and 

2292 simply add a ``'s'`` to the end. A subclass may override by 

2293 assigning :attr:`model_title_plural`. 

2294 """ 

2295 if hasattr(cls, 'model_title_plural'): 

2296 return cls.model_title_plural 

2297 

2298 model_title = cls.get_model_title() 

2299 return f"{model_title}s" 

2300 

2301 @classmethod 

2302 def get_model_key(cls): 

2303 """ 

2304 Returns the "model key" for the master view. 

2305 

2306 This should return a tuple containing one or more "field 

2307 names" corresponding to the primary key for data records. 

2308 

2309 In the most simple/common scenario, where the master view 

2310 represents a Wutta-based SQLAlchemy model, the return value 

2311 for this method is: ``('uuid',)`` 

2312 

2313 But there is no "sane" default for other scenarios, in which 

2314 case subclass should define :attr:`model_key`. If the model 

2315 key cannot be determined, raises ``AttributeError``. 

2316 

2317 :returns: Tuple of field names comprising the model key. 

2318 """ 

2319 if hasattr(cls, 'model_key'): 

2320 keys = cls.model_key 

2321 if isinstance(keys, str): 

2322 keys = [keys] 

2323 return tuple(keys) 

2324 

2325 model_class = cls.get_model_class() 

2326 if model_class: 

2327 mapper = sa.inspect(model_class) 

2328 return tuple([column.key for column in mapper.primary_key]) 

2329 

2330 raise AttributeError(f"you must define model_key for view class: {cls}") 

2331 

2332 @classmethod 

2333 def get_route_prefix(cls): 

2334 """ 

2335 Returns the "route prefix" for the master view. This prefix 

2336 is used for all named routes defined by the view class. 

2337 

2338 For instance if route prefix is ``'widgets'`` then a view 

2339 might have these routes: 

2340 

2341 * ``'widgets'`` 

2342 * ``'widgets.create'`` 

2343 * ``'widgets.edit'`` 

2344 * ``'widgets.delete'`` 

2345 

2346 The default logic will call 

2347 :meth:`get_model_name_normalized()` and simply add an ``'s'`` 

2348 to the end, making it plural. A subclass may override by 

2349 assigning :attr:`route_prefix`. 

2350 """ 

2351 if hasattr(cls, 'route_prefix'): 

2352 return cls.route_prefix 

2353 

2354 model_name = cls.get_model_name_normalized() 

2355 return f'{model_name}s' 

2356 

2357 @classmethod 

2358 def get_permission_prefix(cls): 

2359 """ 

2360 Returns the "permission prefix" for the master view. This 

2361 prefix is used for all permissions defined by the view class. 

2362 

2363 For instance if permission prefix is ``'widgets'`` then a view 

2364 might have these permissions: 

2365 

2366 * ``'widgets.list'`` 

2367 * ``'widgets.create'`` 

2368 * ``'widgets.edit'`` 

2369 * ``'widgets.delete'`` 

2370 

2371 The default logic will call :meth:`get_route_prefix()` and use 

2372 that value as-is. A subclass may override by assigning 

2373 :attr:`permission_prefix`. 

2374 """ 

2375 if hasattr(cls, 'permission_prefix'): 

2376 return cls.permission_prefix 

2377 

2378 return cls.get_route_prefix() 

2379 

2380 @classmethod 

2381 def get_url_prefix(cls): 

2382 """ 

2383 Returns the "URL prefix" for the master view. This prefix is 

2384 used for all URLs defined by the view class. 

2385 

2386 Using the same example as in :meth:`get_route_prefix()`, the 

2387 URL prefix would be ``'/widgets'`` and the view would have 

2388 defined routes for these URLs: 

2389 

2390 * ``/widgets/`` 

2391 * ``/widgets/new`` 

2392 * ``/widgets/XXX/edit`` 

2393 * ``/widgets/XXX/delete`` 

2394 

2395 The default logic will call :meth:`get_route_prefix()` and 

2396 simply add a ``'/'`` to the beginning. A subclass may 

2397 override by assigning :attr:`url_prefix`. 

2398 """ 

2399 if hasattr(cls, 'url_prefix'): 

2400 return cls.url_prefix 

2401 

2402 route_prefix = cls.get_route_prefix() 

2403 return f'/{route_prefix}' 

2404 

2405 @classmethod 

2406 def get_instance_url_prefix(cls): 

2407 """ 

2408 Generate the URL prefix specific to an instance for this model 

2409 view. This will include model key param placeholders; it 

2410 winds up looking like: 

2411 

2412 * ``/widgets/{uuid}`` 

2413 * ``/resources/{foo}|{bar}|{baz}`` 

2414 

2415 The former being the most simple/common, and the latter 

2416 showing what a "composite" model key looks like, with pipe 

2417 symbols separating the key parts. 

2418 """ 

2419 prefix = cls.get_url_prefix() + '/' 

2420 for i, key in enumerate(cls.get_model_key()): 

2421 if i: 

2422 prefix += '|' 

2423 prefix += f'{{{key}}}' 

2424 return prefix 

2425 

2426 @classmethod 

2427 def get_template_prefix(cls): 

2428 """ 

2429 Returns the "template prefix" for the master view. This 

2430 prefix is used to guess which template path to render for a 

2431 given view. 

2432 

2433 Using the same example as in :meth:`get_url_prefix()`, the 

2434 template prefix would also be ``'/widgets'`` and the templates 

2435 assumed for those routes would be: 

2436 

2437 * ``/widgets/index.mako`` 

2438 * ``/widgets/create.mako`` 

2439 * ``/widgets/edit.mako`` 

2440 * ``/widgets/delete.mako`` 

2441 

2442 The default logic will call :meth:`get_url_prefix()` and 

2443 return that value as-is. A subclass may override by assigning 

2444 :attr:`template_prefix`. 

2445 """ 

2446 if hasattr(cls, 'template_prefix'): 

2447 return cls.template_prefix 

2448 

2449 return cls.get_url_prefix() 

2450 

2451 @classmethod 

2452 def get_grid_key(cls): 

2453 """ 

2454 Returns the (presumably) unique key to be used for the primary 

2455 grid in the :meth:`index()` view. This key may also be used 

2456 as the basis (key prefix) for secondary grids. 

2457 

2458 This is called from :meth:`make_model_grid()`; in the 

2459 resulting :class:`~wuttaweb.grids.base.Grid` instance, this 

2460 becomes :attr:`~wuttaweb.grids.base.Grid.key`. 

2461 

2462 The default logic for this method will call 

2463 :meth:`get_route_prefix()` and return that value as-is. A 

2464 subclass may override by assigning :attr:`grid_key`. 

2465 """ 

2466 if hasattr(cls, 'grid_key'): 

2467 return cls.grid_key 

2468 

2469 return cls.get_route_prefix() 

2470 

2471 @classmethod 

2472 def get_config_title(cls): 

2473 """ 

2474 Returns the "config title" for the view/model. 

2475 

2476 The config title is used for page title in the 

2477 :meth:`configure()` view, as well as links to it. It is 

2478 usually plural, e.g. ``"Wutta Widgets"`` in which case that 

2479 winds up being displayed in the web app as: **Configure Wutta 

2480 Widgets** 

2481 

2482 The default logic will call :meth:`get_model_title_plural()` 

2483 and return that as-is. A subclass may override by assigning 

2484 :attr:`config_title`. 

2485 """ 

2486 if hasattr(cls, 'config_title'): 

2487 return cls.config_title 

2488 

2489 return cls.get_model_title_plural() 

2490 

2491 ############################## 

2492 # configuration 

2493 ############################## 

2494 

2495 @classmethod 

2496 def defaults(cls, config): 

2497 """ 

2498 Provide default Pyramid configuration for a master view. 

2499 

2500 This is generally called from within the module's 

2501 ``includeme()`` function, e.g.:: 

2502 

2503 from wuttaweb.views import MasterView 

2504 

2505 class WidgetView(MasterView): 

2506 model_name = 'Widget' 

2507 

2508 def includeme(config): 

2509 WidgetView.defaults(config) 

2510 

2511 :param config: Reference to the app's 

2512 :class:`pyramid:pyramid.config.Configurator` instance. 

2513 """ 

2514 cls._defaults(config) 

2515 

2516 @classmethod 

2517 def _defaults(cls, config): 

2518 route_prefix = cls.get_route_prefix() 

2519 permission_prefix = cls.get_permission_prefix() 

2520 url_prefix = cls.get_url_prefix() 

2521 model_title = cls.get_model_title() 

2522 model_title_plural = cls.get_model_title_plural() 

2523 

2524 # permission group 

2525 config.add_wutta_permission_group(permission_prefix, 

2526 model_title_plural, 

2527 overwrite=False) 

2528 

2529 # index 

2530 if cls.listable: 

2531 config.add_route(route_prefix, f'{url_prefix}/') 

2532 config.add_view(cls, attr='index', 

2533 route_name=route_prefix, 

2534 permission=f'{permission_prefix}.list') 

2535 config.add_wutta_permission(permission_prefix, 

2536 f'{permission_prefix}.list', 

2537 f"Browse / search {model_title_plural}") 

2538 

2539 # create 

2540 if cls.creatable: 

2541 config.add_route(f'{route_prefix}.create', 

2542 f'{url_prefix}/new') 

2543 config.add_view(cls, attr='create', 

2544 route_name=f'{route_prefix}.create', 

2545 permission=f'{permission_prefix}.create') 

2546 config.add_wutta_permission(permission_prefix, 

2547 f'{permission_prefix}.create', 

2548 f"Create new {model_title}") 

2549 

2550 # edit 

2551 if cls.editable: 

2552 instance_url_prefix = cls.get_instance_url_prefix() 

2553 config.add_route(f'{route_prefix}.edit', 

2554 f'{instance_url_prefix}/edit') 

2555 config.add_view(cls, attr='edit', 

2556 route_name=f'{route_prefix}.edit', 

2557 permission=f'{permission_prefix}.edit') 

2558 config.add_wutta_permission(permission_prefix, 

2559 f'{permission_prefix}.edit', 

2560 f"Edit {model_title}") 

2561 

2562 # delete 

2563 if cls.deletable: 

2564 instance_url_prefix = cls.get_instance_url_prefix() 

2565 config.add_route(f'{route_prefix}.delete', 

2566 f'{instance_url_prefix}/delete') 

2567 config.add_view(cls, attr='delete', 

2568 route_name=f'{route_prefix}.delete', 

2569 permission=f'{permission_prefix}.delete') 

2570 config.add_wutta_permission(permission_prefix, 

2571 f'{permission_prefix}.delete', 

2572 f"Delete {model_title}") 

2573 

2574 # bulk delete 

2575 if cls.deletable_bulk: 

2576 config.add_route(f'{route_prefix}.delete_bulk', 

2577 f'{url_prefix}/delete-bulk', 

2578 request_method='POST') 

2579 config.add_view(cls, attr='delete_bulk', 

2580 route_name=f'{route_prefix}.delete_bulk', 

2581 permission=f'{permission_prefix}.delete_bulk') 

2582 config.add_wutta_permission(permission_prefix, 

2583 f'{permission_prefix}.delete_bulk', 

2584 f"Delete {model_title_plural} in bulk") 

2585 

2586 # autocomplete 

2587 if cls.has_autocomplete: 

2588 config.add_route(f'{route_prefix}.autocomplete', 

2589 f'{url_prefix}/autocomplete') 

2590 config.add_view(cls, attr='autocomplete', 

2591 route_name=f'{route_prefix}.autocomplete', 

2592 renderer='json', 

2593 permission=f'{route_prefix}.list') 

2594 

2595 # download 

2596 if cls.downloadable: 

2597 config.add_route(f'{route_prefix}.download', 

2598 f'{instance_url_prefix}/download') 

2599 config.add_view(cls, attr='download', 

2600 route_name=f'{route_prefix}.download', 

2601 permission=f'{permission_prefix}.download') 

2602 config.add_wutta_permission(permission_prefix, 

2603 f'{permission_prefix}.download', 

2604 f"Download file(s) for {model_title}") 

2605 

2606 # execute 

2607 if cls.executable: 

2608 config.add_route(f'{route_prefix}.execute', 

2609 f'{instance_url_prefix}/execute', 

2610 request_method='POST') 

2611 config.add_view(cls, attr='execute', 

2612 route_name=f'{route_prefix}.execute', 

2613 permission=f'{permission_prefix}.execute') 

2614 config.add_wutta_permission(permission_prefix, 

2615 f'{permission_prefix}.execute', 

2616 f"Execute {model_title}") 

2617 

2618 # configure 

2619 if cls.configurable: 

2620 config.add_route(f'{route_prefix}.configure', 

2621 f'{url_prefix}/configure') 

2622 config.add_view(cls, attr='configure', 

2623 route_name=f'{route_prefix}.configure', 

2624 permission=f'{permission_prefix}.configure') 

2625 config.add_wutta_permission(permission_prefix, 

2626 f'{permission_prefix}.configure', 

2627 f"Configure {model_title_plural}") 

2628 

2629 # view 

2630 # nb. always register this one last, so it does not take 

2631 # priority over model-wide action routes, e.g. delete_bulk 

2632 if cls.viewable: 

2633 instance_url_prefix = cls.get_instance_url_prefix() 

2634 config.add_route(f'{route_prefix}.view', instance_url_prefix) 

2635 config.add_view(cls, attr='view', 

2636 route_name=f'{route_prefix}.view', 

2637 permission=f'{permission_prefix}.view') 

2638 config.add_wutta_permission(permission_prefix, 

2639 f'{permission_prefix}.view', 

2640 f"View {model_title}")