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

729 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-14 13:23 -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 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 :term:`data model` class. While not 

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

78 mapped class, 

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

80 

81 The base logic 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 **ROW FEATURES** 

345 

346 .. attribute:: has_rows 

347 

348 Whether the model has "rows" which should also be displayed 

349 when viewing model records. 

350 

351 This the "master switch" for all row features; if this is turned 

352 on then many other things kick in. 

353 

354 See also :attr:`row_model_class`. 

355 

356 .. attribute:: row_model_class 

357 

358 Reference to a :term:`data model` class for the rows. 

359 

360 The base logic should not access this directly but instead call 

361 :meth:`get_row_model_class()`. 

362 

363 .. attribute:: rows_title 

364 

365 Display title for the rows grid. 

366 

367 The base logic should not access this directly but instead call 

368 :meth:`get_rows_title()`. 

369 

370 .. attribute:: row_grid_columns 

371 

372 List of columns for the row grid. 

373 

374 This is optional; see also :meth:`get_row_grid_columns()`. 

375 

376 This is optional; see also :meth:`get_row_grid_columns()`. 

377 

378 .. attribute:: rows_viewable 

379 

380 Boolean indicating whether the row model supports "viewing" - 

381 i.e. it should have a "View" action in the row grid. 

382 

383 (For now) If you enable this, you must also override 

384 :meth:`get_row_action_url_view()`. 

385 

386 .. note:: 

387 This eventually will cause there to be a ``row_view`` route 

388 to be configured as well. 

389 """ 

390 

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

392 # attributes 

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

394 

395 # features 

396 listable = True 

397 has_grid = True 

398 filterable = True 

399 filter_defaults = None 

400 sortable = True 

401 sort_on_backend = True 

402 sort_defaults = None 

403 paginated = True 

404 paginate_on_backend = True 

405 creatable = True 

406 viewable = True 

407 editable = True 

408 deletable = True 

409 deletable_bulk = False 

410 deletable_bulk_quick = False 

411 has_autocomplete = False 

412 downloadable = False 

413 executable = False 

414 execute_progress_template = None 

415 configurable = False 

416 

417 # row features 

418 has_rows = False 

419 rows_filterable = True 

420 rows_filter_defaults = None 

421 rows_sortable = True 

422 rows_sort_on_backend = True 

423 rows_sort_defaults = None 

424 rows_paginated = True 

425 rows_paginate_on_backend = True 

426 rows_viewable = False 

427 

428 # current action 

429 listing = False 

430 creating = False 

431 viewing = False 

432 editing = False 

433 deleting = False 

434 configuring = False 

435 

436 # default DB session 

437 Session = Session 

438 

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

440 # index methods 

441 ############################## 

442 

443 def index(self): 

444 """ 

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

446 

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

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

449 e.g. ``/widgets/``. 

450 

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

452 true. 

453 

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

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

456 

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

458 

459 * :meth:`make_model_grid()` 

460 """ 

461 self.listing = True 

462 

463 context = { 

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

465 } 

466 

467 if self.has_grid: 

468 grid = self.make_model_grid() 

469 

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

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

472 

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

474 context = grid.get_vue_context() 

475 if grid.paginated and grid.paginate_on_backend: 

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

477 return self.json_response(context) 

478 

479 else: # full, not partial 

480 

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

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

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

484 

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

486 kw = {'_query': None, 

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

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

489 

490 context['grid'] = grid 

491 

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

493 

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

495 # create methods 

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

497 

498 def create(self): 

499 """ 

500 View to "create" a new model record. 

501 

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

503 

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

505 true. 

506 

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

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

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

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 * :meth:`create_save_form()` 

518 * :meth:`redirect_after_create()` 

519 """ 

520 self.creating = True 

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

522 

523 if form.validate(): 

524 obj = self.create_save_form(form) 

525 self.Session.flush() 

526 return self.redirect_after_create(obj) 

527 

528 context = { 

529 'form': form, 

530 } 

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

532 

533 def create_save_form(self, form): 

534 """ 

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

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

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

538 

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

540 called by this one: 

541 

542 * :meth:`objectify()` 

543 * :meth:`persist()` 

544 

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

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

547 """ 

548 obj = self.objectify(form) 

549 self.persist(obj) 

550 return obj 

551 

552 def redirect_after_create(self, obj): 

553 """ 

554 Usually, this returns a redirect to which we send the user, 

555 after a new model record has been created. By default this 

556 sends them to the "view" page for the record. 

557 

558 It is called automatically by :meth:`create()`. 

559 """ 

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

561 

562 ############################## 

563 # view methods 

564 ############################## 

565 

566 def view(self): 

567 """ 

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

569 

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

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

572 

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

574 true. 

575 

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

577 values displayed. 

578 

579 Subclass normally should not override this method, but rather 

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

581 this one: 

582 

583 * :meth:`make_model_form()` 

584 * :meth:`configure_form()` 

585 * :meth:`make_row_model_grid()` - if :attr:`has_rows` is true 

586 """ 

587 self.viewing = True 

588 obj = self.get_instance() 

589 form = self.make_model_form(obj, readonly=True) 

590 context = { 

591 'instance': obj, 

592 'form': form, 

593 } 

594 

595 if self.has_rows: 

596 

597 # always make the grid first. note that it already knows 

598 # to "reset" its params when that is requested. 

599 grid = self.make_row_model_grid(obj) 

600 

601 # but if user did request a "reset" then we want to 

602 # redirect so the query string gets cleared out 

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

604 

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

606 kw = {'_query': None, 

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

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

609 

610 # so-called 'partial' requests get just the grid data 

611 if self.request.params.get('partial'): 

612 context = grid.get_vue_context() 

613 if grid.paginated and grid.paginate_on_backend: 

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

615 return self.json_response(context) 

616 

617 context['rows_grid'] = grid 

618 

619 context['xref_buttons'] = self.get_xref_buttons(obj) 

620 

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

622 

623 ############################## 

624 # edit methods 

625 ############################## 

626 

627 def edit(self): 

628 """ 

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

630 

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

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

633 

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

635 true. 

636 

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

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

639 are then persisted to the DB (assuming typical SQLAlchemy 

640 model). 

641 

642 Subclass normally should not override this method, but rather 

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

644 this one: 

645 

646 * :meth:`make_model_form()` 

647 * :meth:`configure_form()` 

648 * :meth:`edit_save_form()` 

649 """ 

650 self.editing = True 

651 instance = self.get_instance() 

652 

653 form = self.make_model_form(instance, 

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

655 

656 if form.validate(): 

657 self.edit_save_form(form) 

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

659 

660 context = { 

661 'instance': instance, 

662 'form': form, 

663 } 

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

665 

666 def edit_save_form(self, form): 

667 """ 

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

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

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

671 

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

673 called by this one: 

674 

675 * :meth:`objectify()` 

676 * :meth:`persist()` 

677 

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

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

680 """ 

681 obj = self.objectify(form) 

682 self.persist(obj) 

683 return obj 

684 

685 ############################## 

686 # delete methods 

687 ############################## 

688 

689 def delete(self): 

690 """ 

691 View to delete an existing model instance. 

692 

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

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

695 

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

697 true. 

698 

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

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

701 must confirm, before deletion actually occurs. 

702 

703 Subclass normally should not override this method, but rather 

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

705 this one: 

706 

707 * :meth:`make_model_form()` 

708 * :meth:`configure_form()` 

709 * :meth:`delete_save_form()` 

710 * :meth:`delete_instance()` 

711 """ 

712 self.deleting = True 

713 instance = self.get_instance() 

714 

715 if not self.is_deletable(instance): 

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

717 

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

719 form = self.make_model_form(instance, 

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

721 button_label_submit="DELETE Forever", 

722 button_icon_submit='trash', 

723 button_type_submit='is-danger') 

724 # ..but *all* fields are readonly 

725 form.readonly_fields = set(form.fields) 

726 

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

728 if form.validate() is not False: 

729 self.delete_save_form(form) 

730 return self.redirect(self.get_index_url()) 

731 

732 context = { 

733 'instance': instance, 

734 'form': form, 

735 } 

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

737 

738 def delete_save_form(self, form): 

739 """ 

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

741 

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

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

744 

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

746 validated the form. 

747 """ 

748 obj = form.model_instance 

749 self.delete_instance(obj) 

750 

751 def delete_instance(self, obj): 

752 """ 

753 Delete the given model instance. 

754 

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

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

757 needed. 

758 

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

760 """ 

761 session = self.app.get_session(obj) 

762 session.delete(obj) 

763 

764 def delete_bulk(self): 

765 """ 

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

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

768 

769 This usually corresponds to a URL like 

770 ``/widgets/delete-bulk``. 

771 

772 By default, this view is included only if 

773 :attr:`deletable_bulk` is true. 

774 

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

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

777 

778 Subclass normally should not override this method, but rather 

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

780 this one: 

781 

782 * :meth:`delete_bulk_action()` 

783 """ 

784 

785 # get current data set from grid 

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

787 grid = self.make_model_grid(paginated=False) 

788 data = grid.get_visible_data() 

789 

790 if self.deletable_bulk_quick: 

791 

792 # delete it all and go back to listing 

793 self.delete_bulk_action(data) 

794 return self.redirect(self.get_index_url()) 

795 

796 else: 

797 

798 # start thread for delete; show progress page 

799 route_prefix = self.get_route_prefix() 

800 key = f'{route_prefix}.delete_bulk' 

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

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

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

804 thread.start() 

805 return self.render_progress(progress) 

806 

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

808 """ """ 

809 model_title_plural = self.get_model_title_plural() 

810 

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

812 session = self.app.make_session() 

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

814 

815 try: 

816 self.delete_bulk_action(records, progress=progress) 

817 

818 except Exception as error: 

819 session.rollback() 

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

821 len(records), model_title_plural, 

822 exc_info=True) 

823 if progress: 

824 progress.handle_error(error) 

825 

826 else: 

827 session.commit() 

828 if progress: 

829 progress.handle_success() 

830 

831 finally: 

832 session.close() 

833 

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

835 """ 

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

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

838 

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

840 record, and if that returns true then it calls 

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

842 updated if one is provided. 

843 

844 Subclass should override if needed. 

845 """ 

846 model_title_plural = self.get_model_title_plural() 

847 

848 def delete(obj, i): 

849 if self.is_deletable(obj): 

850 self.delete_instance(obj) 

851 

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

853 message=f"Deleting {model_title_plural}") 

854 

855 def delete_bulk_make_button(self): 

856 """ """ 

857 route_prefix = self.get_route_prefix() 

858 

859 label = HTML.literal( 

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

861 button = self.make_button(label, 

862 variant='is-danger', 

863 icon_left='trash', 

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

865 ':disabled': 'deleteResultsDisabled'}) 

866 

867 form = HTML.tag('form', 

868 method='post', 

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

870 ref='deleteResultsForm', 

871 class_='control', 

872 c=[ 

873 render_csrf_token(self.request), 

874 button, 

875 ]) 

876 return form 

877 

878 ############################## 

879 # autocomplete methods 

880 ############################## 

881 

882 def autocomplete(self): 

883 """ 

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

885 list of autocomplete results to match. 

886 

887 By default, this view is included only if 

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

889 like ``/widgets/autocomplete``. 

890 

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

892 rather should override the others which this calls: 

893 

894 * :meth:`autocomplete_data()` 

895 * :meth:`autocomplete_normalize()` 

896 """ 

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

898 if not term: 

899 return [] 

900 

901 data = self.autocomplete_data(term) 

902 if not data: 

903 return [] 

904 

905 max_results = 100 # TODO 

906 

907 results = [] 

908 for obj in data[:max_results]: 

909 normal = self.autocomplete_normalize(obj) 

910 if normal: 

911 results.append(normal) 

912 

913 return results 

914 

915 def autocomplete_data(self, term): 

916 """ 

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

918 based on autocomplete search term. This is called by 

919 :meth:`autocomplete()`. 

920 

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

922 

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

924 

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

926 """ 

927 

928 def autocomplete_normalize(self, obj): 

929 """ 

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

931 record, suitable for autocomplete JSON results. This is 

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

933 

934 Subclass may need to override this; default logic is 

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

936 "autocomplete results" dict for the object:: 

937 

938 { 

939 'value': obj.uuid, 

940 'label': str(obj), 

941 } 

942 

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

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

945 

946 :param obj: Model record/instance. 

947 

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

949 above. 

950 """ 

951 return { 

952 'value': obj.uuid, 

953 'label': str(obj), 

954 } 

955 

956 ############################## 

957 # download methods 

958 ############################## 

959 

960 def download(self): 

961 """ 

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

963 

964 This usually corresponds to a URL like 

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

966 for the record. 

967 

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

969 is true. 

970 

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

972 it as a file download response to the client. 

973 

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

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

976 files associated with the model record. This filename is 

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

978 

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

980 

981 Subclass normally should not override this method, but rather 

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

983 this one: 

984 

985 * :meth:`download_path()` 

986 """ 

987 obj = self.get_instance() 

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

989 

990 path = self.download_path(obj, filename) 

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

992 return self.notfound() 

993 

994 return self.file_response(path) 

995 

996 def download_path(self, obj, filename): 

997 """ 

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

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

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

1001 

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

1003 

1004 :param obj: Refefence to the model instance. 

1005 

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

1007 

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

1009 

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

1011 file path should be returned, if applicable. 

1012 

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

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

1015 response. 

1016 """ 

1017 

1018 ############################## 

1019 # execute methods 

1020 ############################## 

1021 

1022 def execute(self): 

1023 """ 

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

1025 

1026 This usually corresponds to a URL like 

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

1028 for the record. 

1029 

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

1031 true. 

1032 

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

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

1035 

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

1037 * batches (not yet implemented; 

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

1039 Manual) 

1040 

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

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

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

1044 live" the data held within the batch. 

1045 

1046 Subclass normally should not override this method, but rather 

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

1048 this one: 

1049 

1050 * :meth:`execute_instance()` 

1051 """ 

1052 route_prefix = self.get_route_prefix() 

1053 model_title = self.get_model_title() 

1054 obj = self.get_instance() 

1055 

1056 # make the progress tracker 

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

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

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

1060 

1061 # start thread for execute; show progress page 

1062 key = self.request.matchdict 

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

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

1065 kwargs={'progress': progress}) 

1066 thread.start() 

1067 return self.render_progress(progress, context={ 

1068 'instance': obj, 

1069 }, template=self.execute_progress_template) 

1070 

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

1072 """ 

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

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

1075 

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

1077 

1078 :param obj: Reference to the model instance. 

1079 

1080 :param user: Reference to the 

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

1082 is doing the execute. 

1083 

1084 :param progress: Optional progress indicator factory. 

1085 """ 

1086 

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

1088 """ """ 

1089 model = self.app.model 

1090 model_title = self.get_model_title() 

1091 

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

1093 session = self.app.make_session() 

1094 

1095 # fetch model instance and user for this session 

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

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

1098 

1099 try: 

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

1101 

1102 except Exception as error: 

1103 session.rollback() 

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

1105 if progress: 

1106 progress.handle_error(error) 

1107 

1108 else: 

1109 session.commit() 

1110 if progress: 

1111 progress.handle_success() 

1112 

1113 finally: 

1114 session.close() 

1115 

1116 ############################## 

1117 # configure methods 

1118 ############################## 

1119 

1120 def configure(self, session=None): 

1121 """ 

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

1123 this master view and/or model. 

1124 

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

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

1127 

1128 The expected workflow is as follows: 

1129 

1130 * user navigates to Configure page 

1131 * user modifies settings and clicks Save 

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

1133 * then it saves user-submitted settings 

1134 

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

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

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

1138 

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

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

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

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

1143 

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

1145 only provide their basic definitions via 

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

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

1148 to achieve the aim(s). 

1149 

1150 See also related methods, used by this one: 

1151 

1152 * :meth:`configure_get_simple_settings()` 

1153 * :meth:`configure_get_context()` 

1154 * :meth:`configure_gather_settings()` 

1155 * :meth:`configure_remove_settings()` 

1156 * :meth:`configure_save_settings()` 

1157 """ 

1158 self.configuring = True 

1159 config_title = self.get_config_title() 

1160 

1161 # was form submitted? 

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

1163 

1164 # maybe just remove settings 

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

1166 self.configure_remove_settings(session=session) 

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

1168 'warning') 

1169 

1170 # reload configure page 

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

1172 

1173 # gather/save settings 

1174 data = get_form_data(self.request) 

1175 settings = self.configure_gather_settings(data) 

1176 self.configure_remove_settings(session=session) 

1177 self.configure_save_settings(settings, session=session) 

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

1179 

1180 # reload configure page 

1181 return self.redirect(self.request.url) 

1182 

1183 # render configure page 

1184 context = self.configure_get_context() 

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

1186 

1187 def configure_get_context( 

1188 self, 

1189 simple_settings=None, 

1190 ): 

1191 """ 

1192 Returns the full context dict, for rendering the 

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

1194 

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

1196 to just name/value). 

1197 

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

1199 "complex" settings etc. 

1200 

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

1202 already initialized. Otherwise it is retrieved via 

1203 :meth:`configure_get_simple_settings()`. 

1204 

1205 :returns: Context dict for the page template. 

1206 """ 

1207 context = {} 

1208 

1209 # simple settings 

1210 if simple_settings is None: 

1211 simple_settings = self.configure_get_simple_settings() 

1212 if simple_settings: 

1213 

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

1215 normalized = {} 

1216 for simple in simple_settings: 

1217 

1218 # name 

1219 name = simple['name'] 

1220 

1221 # value 

1222 if 'value' in simple: 

1223 value = simple['value'] 

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

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

1226 else: 

1227 value = self.config.get(name, default=simple.get('default')) 

1228 

1229 normalized[name] = value 

1230 

1231 # add to template context 

1232 context['simple_settings'] = normalized 

1233 

1234 return context 

1235 

1236 def configure_get_simple_settings(self): 

1237 """ 

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

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

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

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

1242 part of this method's return value.) 

1243 

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

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

1246 

1247 The setting definitions returned must each be a dict of 

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

1249 setting might be:: 

1250 

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

1252 

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

1254 is a more complete example:: 

1255 

1256 { 

1257 'name': 'wutta.production', 

1258 'type': bool, 

1259 'default': False, 

1260 'save_if_empty': False, 

1261 } 

1262 

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

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

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

1266 

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

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

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

1270 

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

1272 Note that their order does not matter since the template 

1273 must explicitly define field layout etc. 

1274 """ 

1275 

1276 def configure_gather_settings( 

1277 self, 

1278 data, 

1279 simple_settings=None, 

1280 ): 

1281 """ 

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

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

1284 

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

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

1287 the simple setting definitions. 

1288 

1289 Subclass may need to override this method if complex settings 

1290 are required. 

1291 

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

1293 

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

1295 already initialized. Otherwise it is retrieved via 

1296 :meth:`configure_get_simple_settings()`. 

1297 

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

1299 in spirit to the definition syntax used in 

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

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

1302 

1303 { 

1304 'name': 'wutta.app_title', 

1305 'value': 'Wutta Wutta', 

1306 } 

1307 

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

1309 

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

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

1312 contain all of them. 

1313 

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

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

1316 saved to DB) unless the setting definition has the 

1317 ``save_if_empty`` flag set. 

1318 """ 

1319 settings = [] 

1320 

1321 # simple settings 

1322 if simple_settings is None: 

1323 simple_settings = self.configure_get_simple_settings() 

1324 if simple_settings: 

1325 

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

1327 for simple in simple_settings: 

1328 name = simple['name'] 

1329 

1330 if name in data: 

1331 value = data[name] 

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

1333 # nb. bool false will be *missing* from data 

1334 value = False 

1335 else: 

1336 value = simple.get('default') 

1337 

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

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

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

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

1342 elif value is None: 

1343 value = '' 

1344 else: 

1345 value = str(value) 

1346 

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

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

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

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

1351 'value': value}) 

1352 

1353 return settings 

1354 

1355 def configure_remove_settings( 

1356 self, 

1357 simple_settings=None, 

1358 session=None, 

1359 ): 

1360 """ 

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

1362 :meth:`configure()`. 

1363 

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

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

1366 

1367 The default logic can handle this automatically for simple 

1368 settings; subclass must override for any complex settings. 

1369 

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

1371 already initialized. Otherwise it is retrieved via 

1372 :meth:`configure_get_simple_settings()`. 

1373 """ 

1374 names = [] 

1375 

1376 # simple settings 

1377 if simple_settings is None: 

1378 simple_settings = self.configure_get_simple_settings() 

1379 if simple_settings: 

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

1381 for simple in simple_settings]) 

1382 

1383 if names: 

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

1385 # point to our primary app DB 

1386 session = session or self.Session() 

1387 for name in names: 

1388 self.app.delete_setting(session, name) 

1389 

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

1391 """ 

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

1393 :meth:`configure()`. 

1394 

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

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

1397 

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

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

1400 """ 

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

1402 # to our primary app DB 

1403 session = session or self.Session() 

1404 for setting in settings: 

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

1406 force_create=True) 

1407 

1408 ############################## 

1409 # grid rendering methods 

1410 ############################## 

1411 

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

1413 """ 

1414 Custom grid value renderer for "boolean" fields. 

1415 

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

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

1418 To use this feature for your grid:: 

1419 

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

1421 """ 

1422 if value is None: 

1423 return 

1424 

1425 return "Yes" if value else "No" 

1426 

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

1428 """ 

1429 Custom grid value renderer for "currency" fields. 

1430 

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

1432 decimal as appropriate, and add the currency symbol. 

1433 

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

1435 default is 2 places. 

1436 

1437 To use this feature for your grid:: 

1438 

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

1440 

1441 # you can also override scale 

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

1443 """ 

1444 

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

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

1447 value = record[key] 

1448 

1449 if value is None: 

1450 return 

1451 

1452 if value < 0: 

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

1454 return fmt.format(0 - value) 

1455 

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

1457 return fmt.format(value) 

1458 

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

1460 """ 

1461 Custom grid value renderer for 

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

1463 

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

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

1466 

1467 To use this feature for your grid:: 

1468 

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

1470 

1471 # you can also override format 

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

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

1474 """ 

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

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

1477 value = record[key] 

1478 

1479 if value is None: 

1480 return 

1481 

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

1483 

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

1485 """ 

1486 Custom grid value renderer for "enum" fields. 

1487 

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

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

1490 

1491 To use this feature for your grid:: 

1492 

1493 from enum import Enum 

1494 

1495 class MyEnum(Enum): 

1496 ONE = 1 

1497 TWO = 2 

1498 THREE = 3 

1499 

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

1501 """ 

1502 if enum: 

1503 original = record[key] 

1504 if original: 

1505 return original.name 

1506 

1507 return value 

1508 

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

1510 """ 

1511 Custom grid value renderer for "notes" fields. 

1512 

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

1514 characters, it is returned as-is. 

1515 

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

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

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

1519 mouse hover. 

1520 

1521 To use this feature for your grid:: 

1522 

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

1524 

1525 # you can also override maxlen 

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

1527 """ 

1528 if value is None: 

1529 return 

1530 

1531 if len(value) < maxlen: 

1532 return value 

1533 

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

1535 

1536 ############################## 

1537 # support methods 

1538 ############################## 

1539 

1540 def get_class_hierarchy(self, topfirst=True): 

1541 """ 

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

1543 class inherits. 

1544 

1545 This is a wrapper around 

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

1547 """ 

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

1549 

1550 def has_perm(self, name): 

1551 """ 

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

1553 

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

1555 ``name`` before passing it on to 

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

1557 

1558 For instance within the 

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

1560 result:: 

1561 

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

1563 

1564 self.has_perm('edit') 

1565 

1566 So this shortcut only applies to permissions defined for the 

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

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

1569 different prefix). 

1570 """ 

1571 permission_prefix = self.get_permission_prefix() 

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

1573 

1574 def has_any_perm(self, *names): 

1575 """ 

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

1577 permissions. 

1578 

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

1580 none do, returns ``False``. 

1581 """ 

1582 for name in names: 

1583 if self.has_perm(name): 

1584 return True 

1585 return False 

1586 

1587 def make_button( 

1588 self, 

1589 label, 

1590 variant=None, 

1591 primary=False, 

1592 url=None, 

1593 **kwargs, 

1594 ): 

1595 """ 

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

1597 

1598 :param label: Text label for the button. 

1599 

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

1601 for the button. Buefy and Oruga represent this differently 

1602 but this logic expects the Buefy format 

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

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

1605 terminology. 

1606 

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

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

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

1610 

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

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

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

1614 

1615 This is the preferred method where applicable, since it 

1616 avoids the Buefy vs. Oruga confusion, and the 

1617 implementation can change in the future. 

1618 

1619 :param url: Specify this (instead of ``href``) to make the 

1620 button act like a link. This will yield something like: 

1621 ``<b-button tag="a" href="{url}">`` 

1622 

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

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

1625 attributes on the button tag. 

1626 

1627 **NB.** You cannot specify a ``tag`` kwarg, for technical 

1628 reasons. 

1629 

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

1631 along the lines of: 

1632 

1633 .. code-block:: 

1634 

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

1636 icon-pack="fas" 

1637 icon-left="hand-pointer"> 

1638 Click Me 

1639 </b-button> 

1640 """ 

1641 btn_kw = kwargs 

1642 btn_kw.setdefault('c', label) 

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

1644 

1645 if 'type' not in btn_kw: 

1646 if variant: 

1647 btn_kw['type'] = variant 

1648 elif primary: 

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

1650 

1651 if url: 

1652 btn_kw['href'] = url 

1653 

1654 button = HTML.tag('b-button', **btn_kw) 

1655 

1656 if url: 

1657 # nb. unfortunately HTML.tag() calls its first arg 'tag' 

1658 # and so we can't pass a kwarg with that name...so instead 

1659 # we patch that into place manually 

1660 button = str(button) 

1661 button = button.replace('<b-button ', 

1662 '<b-button tag="a" ') 

1663 button = HTML.literal(button) 

1664 

1665 return button 

1666 

1667 def get_xref_buttons(self, obj): 

1668 """ 

1669 Should return a list of "cross-reference" buttons to be shown 

1670 when viewing the given object. 

1671 

1672 Default logic always returns empty list; subclass can override 

1673 as needed. 

1674 

1675 If applicable, this method should do its own permission checks 

1676 and only include the buttons current user should be allowed to 

1677 see/use. 

1678 

1679 See also :meth:`make_button()` - example:: 

1680 

1681 def get_xref_buttons(self, product): 

1682 buttons = [] 

1683 if self.request.has_perm('external_products.view'): 

1684 url = self.request.route_url('external_products.view', 

1685 id=product.external_id) 

1686 buttons.append(self.make_button("View External", url=url)) 

1687 return buttons 

1688 """ 

1689 return [] 

1690 

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

1692 """ 

1693 Create and return a 

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

1695 given key. 

1696 

1697 This is normally done just before calling 

1698 :meth:`render_progress()`. 

1699 """ 

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

1701 

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

1703 """ 

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

1705 

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

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

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

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

1710 is redirected to the final destination. 

1711 

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

1713 

1714 :param progress: Progress indicator instance as returned by 

1715 :meth:`make_progress()`. 

1716 

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

1718 """ 

1719 template = template or '/progress.mako' 

1720 context = context or {} 

1721 context['progress'] = progress 

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

1723 

1724 def render_to_response(self, template, context): 

1725 """ 

1726 Locate and render an appropriate template, with the given 

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

1728 

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

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

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

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

1733 :meth:`get_fallback_templates()`. 

1734 

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

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

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

1738 

1739 * ``/widgets/edit.mako`` 

1740 * ``/master/edit.mako`` 

1741 

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

1743 It then calls 

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

1745 returns the result. 

1746 

1747 :param template: Base name for the template. 

1748 

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

1750 

1751 :returns: Response object containing the rendered template. 

1752 """ 

1753 defaults = { 

1754 'master': self, 

1755 'route_prefix': self.get_route_prefix(), 

1756 'index_title': self.get_index_title(), 

1757 'index_url': self.get_index_url(), 

1758 'model_title': self.get_model_title(), 

1759 'config_title': self.get_config_title(), 

1760 } 

1761 

1762 # merge defaults + caller-provided context 

1763 defaults.update(context) 

1764 context = defaults 

1765 

1766 # add crud flags if we have an instance 

1767 if 'instance' in context: 

1768 instance = context['instance'] 

1769 if 'instance_title' not in context: 

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

1771 if 'instance_editable' not in context: 

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

1773 if 'instance_deletable' not in context: 

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

1775 

1776 # supplement context further if needed 

1777 context = self.get_template_context(context) 

1778 

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

1780 page_templates = self.get_page_templates(template) 

1781 mako_path = page_templates[0] 

1782 try: 

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

1784 except IOError: 

1785 

1786 # failing that, try one or more fallback templates 

1787 for fallback in page_templates[1:]: 

1788 try: 

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

1790 except IOError: 

1791 pass 

1792 

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

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

1795 # let that error raise on up 

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

1797 

1798 def get_template_context(self, context): 

1799 """ 

1800 This method should return the "complete" context for rendering 

1801 the current view template. 

1802 

1803 Default logic for this method returns the given context 

1804 unchanged. 

1805 

1806 You may wish to override to pass extra context to the view 

1807 template. Check :attr:`viewing` and similar, or 

1808 ``request.current_route_name`` etc. in order to add extra 

1809 context only for certain view templates. 

1810 

1811 :params: context: The context dict we have so far, 

1812 auto-provided by the master view logic. 

1813 

1814 :returns: Final context dict for the template. 

1815 """ 

1816 return context 

1817 

1818 def get_page_templates(self, template): 

1819 """ 

1820 Returns a list of all templates which can be attempted, to 

1821 render the current page. This is called by 

1822 :meth:`render_to_response()`. 

1823 

1824 The list should be in order of preference, e.g. the first 

1825 entry will be the most "specific" template, with subsequent 

1826 entries becoming more generic. 

1827 

1828 In practice this method defines the first entry but calls 

1829 :meth:`get_fallback_templates()` for the rest. 

1830 

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

1832 ``'view'``. 

1833 

1834 :returns: List of template paths to be tried, based on the 

1835 specified template. For instance if ``template`` is 

1836 ``'view'`` this will (by default) return:: 

1837 

1838 [ 

1839 '/widgets/view.mako', 

1840 '/master/view.mako', 

1841 ] 

1842 

1843 """ 

1844 template_prefix = self.get_template_prefix() 

1845 page_templates = [f'{template_prefix}/{template}.mako'] 

1846 page_templates.extend(self.get_fallback_templates(template)) 

1847 return page_templates 

1848 

1849 def get_fallback_templates(self, template): 

1850 """ 

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

1852 attempted for rendering the current page. See also 

1853 :meth:`get_page_templates()`. 

1854 

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

1856 ``'view'``. 

1857 

1858 :returns: List of template paths to be tried, based on the 

1859 specified template. For instance if ``template`` is 

1860 ``'view'`` this will (by default) return:: 

1861 

1862 ['/master/view.mako'] 

1863 """ 

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

1865 

1866 def get_index_title(self): 

1867 """ 

1868 Returns the main index title for the master view. 

1869 

1870 By default this returns the value from 

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

1872 needed. 

1873 """ 

1874 return self.get_model_title_plural() 

1875 

1876 def get_index_url(self, **kwargs): 

1877 """ 

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

1879 

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

1881 """ 

1882 if self.listable: 

1883 route_prefix = self.get_route_prefix() 

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

1885 

1886 def set_labels(self, obj): 

1887 """ 

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

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

1890 

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

1892 :meth:`configure_form()`. 

1893 

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

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

1896 

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

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

1899 

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

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

1902 """ 

1903 labels = self.collect_labels() 

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

1905 obj.set_label(key, label) 

1906 

1907 def collect_labels(self): 

1908 """ 

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

1910 

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

1912 like so:: 

1913 

1914 from wuttaweb.views import MasterView 

1915 

1916 class WidgetView(MasterView): 

1917 

1918 labels = { 

1919 'id': "Widget ID", 

1920 'serial_no': "Serial Number", 

1921 } 

1922 

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

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

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

1926 wins. 

1927 

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

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

1930 

1931 :returns: Dict of all labels found. 

1932 """ 

1933 labels = {} 

1934 hierarchy = self.get_class_hierarchy() 

1935 for cls in hierarchy: 

1936 if hasattr(cls, 'labels'): 

1937 labels.update(cls.labels) 

1938 return labels 

1939 

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

1941 """ 

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

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

1944 

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

1946 

1947 * :meth:`get_grid_key()` 

1948 * :meth:`get_grid_columns()` 

1949 * :meth:`get_grid_data()` 

1950 * :meth:`configure_grid()` 

1951 """ 

1952 if 'key' not in kwargs: 

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

1954 

1955 if 'model_class' not in kwargs: 

1956 model_class = self.get_model_class() 

1957 if model_class: 

1958 kwargs['model_class'] = model_class 

1959 

1960 if 'columns' not in kwargs: 

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

1962 

1963 if 'data' not in kwargs: 

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

1965 session=session) 

1966 

1967 if 'actions' not in kwargs: 

1968 actions = [] 

1969 

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

1971 

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

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

1974 url=self.get_action_url_view)) 

1975 

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

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

1978 url=self.get_action_url_edit)) 

1979 

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

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

1982 url=self.get_action_url_delete, 

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

1984 

1985 kwargs['actions'] = actions 

1986 

1987 if 'tools' not in kwargs: 

1988 tools = [] 

1989 

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

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

1992 

1993 kwargs['tools'] = tools 

1994 

1995 if hasattr(self, 'grid_row_class'): 

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

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

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

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

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

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

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

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

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

2005 

2006 grid = self.make_grid(**kwargs) 

2007 self.configure_grid(grid) 

2008 grid.load_settings() 

2009 return grid 

2010 

2011 def get_grid_columns(self): 

2012 """ 

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

2014 :meth:`index()` view. 

2015 

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

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

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

2019 

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

2021 (try to) generate its own default list. 

2022 

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

2024 can override this method if needed. 

2025 

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

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

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

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

2030 remove or replace some of those within 

2031 :meth:`configure_grid()`. 

2032 """ 

2033 if hasattr(self, 'grid_columns'): 

2034 return self.grid_columns 

2035 

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

2037 """ 

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

2039 

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

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

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

2043 

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

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

2046 empty list. Subclass should override as needed. 

2047 """ 

2048 query = self.get_query(session=session) 

2049 if query: 

2050 return query 

2051 return [] 

2052 

2053 def get_query(self, session=None): 

2054 """ 

2055 Returns the main SQLAlchemy query object for the 

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

2057 :meth:`get_grid_data()`. 

2058 

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

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

2061 """ 

2062 model_class = self.get_model_class() 

2063 if model_class: 

2064 session = session or self.Session() 

2065 return session.query(model_class) 

2066 

2067 def configure_grid(self, grid): 

2068 """ 

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

2070 

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

2072 

2073 There is minimal default logic here; subclass should override 

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

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

2076 based on request details etc. 

2077 """ 

2078 if 'uuid' in grid.columns: 

2079 grid.columns.remove('uuid') 

2080 

2081 self.set_labels(grid) 

2082 

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

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

2085 # for key in self.get_model_key(): 

2086 # grid.set_link(key) 

2087 

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

2089 """ 

2090 This should return the appropriate model instance, based on 

2091 the ``matchdict`` of model keys. 

2092 

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

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

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

2096 (route/params). 

2097 

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

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

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

2101 

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

2103 obj = self.get_instance(matchdict=keys) 

2104 

2105 Although some models may have different, possibly composite 

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

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

2108 

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

2110 raise a 404 error, 

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

2112 

2113 Default implementation of this method should work okay for 

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

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

2116 may need to define. 

2117 

2118 .. warning:: 

2119 

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

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

2122 

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

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

2125 

2126 def get_instance(self, **kwargs): 

2127 

2128 # ..try to locate instance.. 

2129 obj = self.locate_instance_somehow() 

2130 

2131 if not obj: 

2132 

2133 # NB. THIS MAY NOT WORK AS EXPECTED 

2134 #return self.notfound() 

2135 

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

2137 raise self.notfound() 

2138 

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

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

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

2142 will kick in and control flow goes elsewhere. 

2143 """ 

2144 model_class = self.get_model_class() 

2145 if model_class: 

2146 session = session or self.Session() 

2147 matchdict = matchdict or self.request.matchdict 

2148 

2149 def filtr(query, model_key): 

2150 key = matchdict[model_key] 

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

2152 return query 

2153 

2154 query = session.query(model_class) 

2155 

2156 for key in self.get_model_key(): 

2157 query = filtr(query, key) 

2158 

2159 try: 

2160 return query.one() 

2161 except orm.exc.NoResultFound: 

2162 pass 

2163 

2164 raise self.notfound() 

2165 

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

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

2168 

2169 def get_instance_title(self, instance): 

2170 """ 

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

2172 in the page title when viewing etc. 

2173 

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

2175 subclass may override if needed. 

2176 """ 

2177 return str(instance) or "(no title)" 

2178 

2179 def get_action_route_kwargs(self, obj): 

2180 """ 

2181 Get a dict of route kwargs for the given object. 

2182 

2183 This is called from :meth:`get_action_url()` and must return 

2184 kwargs suitable for use with ``request.route_url()``. 

2185 

2186 In practice this should return a dict which has keys for each 

2187 field from :meth:`get_model_key()` and values which come from 

2188 the object. 

2189 

2190 :param obj: Model instance object. 

2191 

2192 :returns: The dict of route kwargs for the object. 

2193 """ 

2194 try: 

2195 return dict([(key, obj[key]) 

2196 for key in self.get_model_key()]) 

2197 except TypeError: 

2198 return dict([(key, getattr(obj, key)) 

2199 for key in self.get_model_key()]) 

2200 

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

2202 """ 

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

2204 

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

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

2207 

2208 It calls :meth:`get_action_route_kwargs()` and then passes 

2209 those along with route name to ``request.route_url()``, and 

2210 returns the result. 

2211 

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

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

2214 

2215 :param obj: Model instance object. 

2216 

2217 :param \**kwargs: Additional kwargs to be passed to 

2218 ``request.route_url()``, if needed. 

2219 """ 

2220 kw = self.get_action_route_kwargs(obj) 

2221 kw.update(kwargs) 

2222 route_prefix = self.get_route_prefix() 

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

2224 

2225 def get_action_url_view(self, obj, i): 

2226 """ 

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

2228 

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

2230 represents the object's key/ID. 

2231 

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

2233 """ 

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

2235 

2236 def get_action_url_edit(self, obj, i): 

2237 """ 

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

2239 applicable. 

2240 

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

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

2243 

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

2245 this method will return ``None``. 

2246 

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

2248 """ 

2249 if self.is_editable(obj): 

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

2251 

2252 def get_action_url_delete(self, obj, i): 

2253 """ 

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

2255 applicable. 

2256 

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

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

2259 

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

2261 this method will return ``None``. 

2262 

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

2264 """ 

2265 if self.is_deletable(obj): 

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

2267 

2268 def is_editable(self, obj): 

2269 """ 

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

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

2272 

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

2274 if needed. 

2275 

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

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

2278 """ 

2279 return True 

2280 

2281 def is_deletable(self, obj): 

2282 """ 

2283 Returns a boolean indicating whether "delete" should be 

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

2285 

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

2287 if needed. 

2288 

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

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

2291 """ 

2292 return True 

2293 

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

2295 """ 

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

2297 for the view model. 

2298 

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

2300 e.g.: 

2301 

2302 * :meth:`view()` 

2303 * :meth:`edit()` 

2304 

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

2306 

2307 * :meth:`get_form_fields()` 

2308 * :meth:`configure_form()` 

2309 """ 

2310 if 'model_class' not in kwargs: 

2311 model_class = self.get_model_class() 

2312 if model_class: 

2313 kwargs['model_class'] = model_class 

2314 

2315 kwargs['model_instance'] = model_instance 

2316 

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

2318 fields = self.get_form_fields() 

2319 if fields: 

2320 kwargs['fields'] = fields 

2321 

2322 form = self.make_form(**kwargs) 

2323 self.configure_form(form) 

2324 return form 

2325 

2326 def get_form_fields(self): 

2327 """ 

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

2329 

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

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

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

2333 

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

2335 (try to) generate its own default list. 

2336 

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

2338 can override this method if needed. 

2339 

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

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

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

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

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

2345 """ 

2346 if hasattr(self, 'form_fields'): 

2347 return self.form_fields 

2348 

2349 def configure_form(self, form): 

2350 """ 

2351 Configure the given model form, as needed. 

2352 

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

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

2355 

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

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

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

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

2360 values for a record. 

2361 

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

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

2364 can further modify it based on request details etc. 

2365 """ 

2366 form.remove('uuid') 

2367 

2368 self.set_labels(form) 

2369 

2370 if self.editing: 

2371 for key in self.get_model_key(): 

2372 form.set_readonly(key) 

2373 

2374 def objectify(self, form): 

2375 """ 

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

2377 validated form data. 

2378 

2379 In simple cases this may just return the 

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

2381 

2382 When dealing with SQLAlchemy models it would return a proper 

2383 mapped instance, creating it if necessary. 

2384 

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

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

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

2388 the data. 

2389 

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

2391 """ 

2392 

2393 # use ColanderAlchemy magic if possible 

2394 schema = form.get_schema() 

2395 if hasattr(schema, 'objectify'): 

2396 # this returns a model instance 

2397 return schema.objectify(form.validated, 

2398 context=form.model_instance) 

2399 

2400 # otherwise return data dict as-is 

2401 return form.validated 

2402 

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

2404 """ 

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

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

2407 

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

2409 a model instance which already reflects the validated form 

2410 data. 

2411 

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

2413 override if needed. 

2414 

2415 :param obj: Model instance object as produced by 

2416 :meth:`objectify()`. 

2417 

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

2419 """ 

2420 model = self.app.model 

2421 model_class = self.get_model_class() 

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

2423 

2424 # add sqlalchemy model to session 

2425 session = session or self.Session() 

2426 session.add(obj) 

2427 

2428 ############################## 

2429 # row methods 

2430 ############################## 

2431 

2432 def get_rows_title(self): 

2433 """ 

2434 Returns the display title for model **rows** grid, if 

2435 applicable/desired. Only relevant if :attr:`has_rows` is 

2436 true. 

2437 

2438 There is no default here, but subclass may override by 

2439 assigning :attr:`rows_title`. 

2440 """ 

2441 if hasattr(self, 'rows_title'): 

2442 return self.rows_title 

2443 

2444 def make_row_model_grid(self, obj, **kwargs): 

2445 """ 

2446 Create and return a grid for a record's **rows** data, for use 

2447 in :meth:`view()`. Only applicable if :attr:`has_rows` is 

2448 true. 

2449 

2450 :param obj: Current model instance for which rows data is 

2451 being displayed. 

2452 

2453 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the 

2454 rows data. 

2455 

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

2457 

2458 * :meth:`get_row_grid_key()` 

2459 * :meth:`get_row_grid_columns()` 

2460 * :meth:`get_row_grid_data()` 

2461 * :meth:`configure_row_grid()` 

2462 """ 

2463 if 'key' not in kwargs: 

2464 kwargs['key'] = self.get_row_grid_key() 

2465 

2466 if 'model_class' not in kwargs: 

2467 model_class = self.get_row_model_class() 

2468 if model_class: 

2469 kwargs['model_class'] = model_class 

2470 

2471 if 'columns' not in kwargs: 

2472 kwargs['columns'] = self.get_row_grid_columns() 

2473 

2474 if 'data' not in kwargs: 

2475 kwargs['data'] = self.get_row_grid_data(obj) 

2476 

2477 kwargs.setdefault('filterable', self.rows_filterable) 

2478 kwargs.setdefault('filter_defaults', self.rows_filter_defaults) 

2479 kwargs.setdefault('sortable', self.rows_sortable) 

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

2481 kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend) 

2482 kwargs.setdefault('sort_defaults', self.rows_sort_defaults) 

2483 kwargs.setdefault('paginated', self.rows_paginated) 

2484 kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend) 

2485 

2486 if 'actions' not in kwargs: 

2487 actions = [] 

2488 

2489 if self.rows_viewable: 

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

2491 url=self.get_row_action_url_view)) 

2492 

2493 if actions: 

2494 kwargs['actions'] = actions 

2495 

2496 grid = self.make_grid(**kwargs) 

2497 self.configure_row_grid(grid) 

2498 grid.load_settings() 

2499 return grid 

2500 

2501 def get_row_grid_key(self): 

2502 """ 

2503 Returns the (presumably) unique key to be used for the 

2504 **rows** grid in :meth:`view()`. Only relevant if 

2505 :attr:`has_rows` is true. 

2506 

2507 This is called from :meth:`make_row_model_grid()`; in the 

2508 resulting grid, this becomes 

2509 :attr:`~wuttaweb.grids.base.Grid.key`. 

2510 

2511 Whereas you can define :attr:`grid_key` for the main grid, the 

2512 row grid key is always generated dynamically. This 

2513 incorporates the current record key (whose rows are in the 

2514 grid) so that the rows grid for each record is unique. 

2515 """ 

2516 parts = [self.get_grid_key()] 

2517 for key in self.get_model_key(): 

2518 parts.append(str(self.request.matchdict[key])) 

2519 return '.'.join(parts) 

2520 

2521 def get_row_grid_columns(self): 

2522 """ 

2523 Returns the default list of column names for the **rows** 

2524 grid, for use in :meth:`view()`. Only relevant if 

2525 :attr:`has_rows` is true. 

2526 

2527 This is called by :meth:`make_row_model_grid()`; in the 

2528 resulting grid, this becomes 

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

2530 

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

2532 (try to) generate its own default list. 

2533 

2534 Subclass may define :attr:`row_grid_columns` for simple cases, 

2535 or can override this method if needed. 

2536 

2537 Also note that :meth:`configure_row_grid()` may be used to 

2538 further modify the final column set, regardless of what this 

2539 method returns. So a common pattern is to declare all 

2540 "supported" columns by setting :attr:`row_grid_columns` but 

2541 then optionally remove or replace some of those within 

2542 :meth:`configure_row_grid()`. 

2543 """ 

2544 if hasattr(self, 'row_grid_columns'): 

2545 return self.row_grid_columns 

2546 

2547 def get_row_grid_data(self, obj): 

2548 """ 

2549 Returns the data for the **rows** grid, for use in 

2550 :meth:`view()`. Only relevant if :attr:`has_rows` is true. 

2551 

2552 This is called by :meth:`make_row_model_grid()`; in the 

2553 resulting grid, this becomes 

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

2555 

2556 Default logic not implemented; subclass must define this. 

2557 """ 

2558 raise NotImplementedError 

2559 

2560 def configure_row_grid(self, grid): 

2561 """ 

2562 Configure the **rows** grid for use in :meth:`view()`. Only 

2563 relevant if :attr:`has_rows` is true. 

2564 

2565 This is called by :meth:`make_row_model_grid()`. 

2566 

2567 There is minimal default logic here; subclass should override 

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

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

2570 based on request details etc. 

2571 """ 

2572 grid.remove('uuid') 

2573 self.set_row_labels(grid) 

2574 

2575 def set_row_labels(self, obj): 

2576 """ 

2577 Set label overrides on a **row** form or grid, based on what 

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

2579 

2580 This is called automatically from 

2581 :meth:`configure_row_grid()` and 

2582 :meth:`configure_row_form()`. 

2583 

2584 This calls :meth:`collect_row_labels()` to find everything, 

2585 then it assigns the labels using one of (based on ``obj`` 

2586 type): 

2587 

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

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

2590 

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

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

2593 """ 

2594 labels = self.collect_row_labels() 

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

2596 obj.set_label(key, label) 

2597 

2598 def collect_row_labels(self): 

2599 """ 

2600 Collect all **row** labels defined within the view class 

2601 hierarchy. 

2602 

2603 This is called by :meth:`set_row_labels()`. 

2604 

2605 :returns: Dict of all labels found. 

2606 """ 

2607 labels = {} 

2608 hierarchy = self.get_class_hierarchy() 

2609 for cls in hierarchy: 

2610 if hasattr(cls, 'row_labels'): 

2611 labels.update(cls.row_labels) 

2612 return labels 

2613 

2614 def get_row_action_url_view(self, row, i): 

2615 """ 

2616 Must return the "view" action url for the given row object. 

2617 

2618 Only relevant if :attr:`rows_viewable` is true. 

2619 

2620 There is no default logic; subclass must override if needed. 

2621 """ 

2622 raise NotImplementedError 

2623 

2624 ############################## 

2625 # class methods 

2626 ############################## 

2627 

2628 @classmethod 

2629 def get_model_class(cls): 

2630 """ 

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

2632 

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

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

2635 

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

2637 assigning :attr:`model_class`. 

2638 

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

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

2641 :attr:`model_name`. 

2642 """ 

2643 if hasattr(cls, 'model_class'): 

2644 return cls.model_class 

2645 

2646 @classmethod 

2647 def get_model_name(cls): 

2648 """ 

2649 Returns the model name for the view. 

2650 

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

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

2653 *singular*, not plural.) 

2654 

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

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

2657 assigning :attr:`model_name`. 

2658 """ 

2659 if hasattr(cls, 'model_name'): 

2660 return cls.model_name 

2661 

2662 return cls.get_model_class().__name__ 

2663 

2664 @classmethod 

2665 def get_model_name_normalized(cls): 

2666 """ 

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

2668 

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

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

2671 *singular*, not plural.) 

2672 

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

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

2675 assigning :attr:`model_name_normalized`. 

2676 """ 

2677 if hasattr(cls, 'model_name_normalized'): 

2678 return cls.model_name_normalized 

2679 

2680 return cls.get_model_name().lower() 

2681 

2682 @classmethod 

2683 def get_model_title(cls): 

2684 """ 

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

2686 

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

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

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

2690 

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

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

2693 :attr:`model_title`. 

2694 """ 

2695 if hasattr(cls, 'model_title'): 

2696 return cls.model_title 

2697 

2698 return cls.get_model_name() 

2699 

2700 @classmethod 

2701 def get_model_title_plural(cls): 

2702 """ 

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

2704 

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

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

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

2708 

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

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

2711 assigning :attr:`model_title_plural`. 

2712 """ 

2713 if hasattr(cls, 'model_title_plural'): 

2714 return cls.model_title_plural 

2715 

2716 model_title = cls.get_model_title() 

2717 return f"{model_title}s" 

2718 

2719 @classmethod 

2720 def get_model_key(cls): 

2721 """ 

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

2723 

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

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

2726 

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

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

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

2730 

2731 Any class mapped via SQLAlchemy should be supported 

2732 automatically, the keys are determined from class inspection. 

2733 

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

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

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

2737 

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

2739 """ 

2740 if hasattr(cls, 'model_key'): 

2741 keys = cls.model_key 

2742 if isinstance(keys, str): 

2743 keys = [keys] 

2744 return tuple(keys) 

2745 

2746 model_class = cls.get_model_class() 

2747 if model_class: 

2748 # nb. we want the primary key but must avoid column names 

2749 # in case mapped class uses different prop keys 

2750 inspector = sa.inspect(model_class) 

2751 keys = [col.name for col in inspector.primary_key] 

2752 return tuple([prop.key for prop in inspector.column_attrs 

2753 if all([col.name in keys for col in prop.columns])]) 

2754 

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

2756 

2757 @classmethod 

2758 def get_route_prefix(cls): 

2759 """ 

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

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

2762 

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

2764 might have these routes: 

2765 

2766 * ``'widgets'`` 

2767 * ``'widgets.create'`` 

2768 * ``'widgets.edit'`` 

2769 * ``'widgets.delete'`` 

2770 

2771 The default logic will call 

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

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

2774 assigning :attr:`route_prefix`. 

2775 """ 

2776 if hasattr(cls, 'route_prefix'): 

2777 return cls.route_prefix 

2778 

2779 model_name = cls.get_model_name_normalized() 

2780 return f'{model_name}s' 

2781 

2782 @classmethod 

2783 def get_permission_prefix(cls): 

2784 """ 

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

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

2787 

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

2789 might have these permissions: 

2790 

2791 * ``'widgets.list'`` 

2792 * ``'widgets.create'`` 

2793 * ``'widgets.edit'`` 

2794 * ``'widgets.delete'`` 

2795 

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

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

2798 :attr:`permission_prefix`. 

2799 """ 

2800 if hasattr(cls, 'permission_prefix'): 

2801 return cls.permission_prefix 

2802 

2803 return cls.get_route_prefix() 

2804 

2805 @classmethod 

2806 def get_url_prefix(cls): 

2807 """ 

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

2809 used for all URLs defined by the view class. 

2810 

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

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

2813 defined routes for these URLs: 

2814 

2815 * ``/widgets/`` 

2816 * ``/widgets/new`` 

2817 * ``/widgets/XXX/edit`` 

2818 * ``/widgets/XXX/delete`` 

2819 

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

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

2822 override by assigning :attr:`url_prefix`. 

2823 """ 

2824 if hasattr(cls, 'url_prefix'): 

2825 return cls.url_prefix 

2826 

2827 route_prefix = cls.get_route_prefix() 

2828 return f'/{route_prefix}' 

2829 

2830 @classmethod 

2831 def get_instance_url_prefix(cls): 

2832 """ 

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

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

2835 winds up looking like: 

2836 

2837 * ``/widgets/{uuid}`` 

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

2839 

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

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

2842 symbols separating the key parts. 

2843 """ 

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

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

2846 if i: 

2847 prefix += '|' 

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

2849 return prefix 

2850 

2851 @classmethod 

2852 def get_template_prefix(cls): 

2853 """ 

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

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

2856 given view. 

2857 

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

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

2860 assumed for those routes would be: 

2861 

2862 * ``/widgets/index.mako`` 

2863 * ``/widgets/create.mako`` 

2864 * ``/widgets/edit.mako`` 

2865 * ``/widgets/delete.mako`` 

2866 

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

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

2869 :attr:`template_prefix`. 

2870 """ 

2871 if hasattr(cls, 'template_prefix'): 

2872 return cls.template_prefix 

2873 

2874 return cls.get_url_prefix() 

2875 

2876 @classmethod 

2877 def get_grid_key(cls): 

2878 """ 

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

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

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

2882 

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

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

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

2886 

2887 The default logic for this method will call 

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

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

2890 """ 

2891 if hasattr(cls, 'grid_key'): 

2892 return cls.grid_key 

2893 

2894 return cls.get_route_prefix() 

2895 

2896 @classmethod 

2897 def get_config_title(cls): 

2898 """ 

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

2900 

2901 The config title is used for page title in the 

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

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

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

2905 Widgets** 

2906 

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

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

2909 :attr:`config_title`. 

2910 """ 

2911 if hasattr(cls, 'config_title'): 

2912 return cls.config_title 

2913 

2914 return cls.get_model_title_plural() 

2915 

2916 @classmethod 

2917 def get_row_model_class(cls): 

2918 """ 

2919 Returns the **row** model class for the view, if defined. 

2920 Only relevant if :attr:`has_rows` is true. 

2921 

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

2923 assigning :attr:`row_model_class`. 

2924 """ 

2925 if hasattr(cls, 'row_model_class'): 

2926 return cls.row_model_class 

2927 

2928 ############################## 

2929 # configuration 

2930 ############################## 

2931 

2932 @classmethod 

2933 def defaults(cls, config): 

2934 """ 

2935 Provide default Pyramid configuration for a master view. 

2936 

2937 This is generally called from within the module's 

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

2939 

2940 from wuttaweb.views import MasterView 

2941 

2942 class WidgetView(MasterView): 

2943 model_name = 'Widget' 

2944 

2945 def includeme(config): 

2946 WidgetView.defaults(config) 

2947 

2948 :param config: Reference to the app's 

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

2950 """ 

2951 cls._defaults(config) 

2952 

2953 @classmethod 

2954 def _defaults(cls, config): 

2955 route_prefix = cls.get_route_prefix() 

2956 permission_prefix = cls.get_permission_prefix() 

2957 url_prefix = cls.get_url_prefix() 

2958 model_title = cls.get_model_title() 

2959 model_title_plural = cls.get_model_title_plural() 

2960 

2961 # permission group 

2962 config.add_wutta_permission_group(permission_prefix, 

2963 model_title_plural, 

2964 overwrite=False) 

2965 

2966 # index 

2967 if cls.listable: 

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

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

2970 route_name=route_prefix, 

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

2972 config.add_wutta_permission(permission_prefix, 

2973 f'{permission_prefix}.list', 

2974 f"Browse / search {model_title_plural}") 

2975 

2976 # create 

2977 if cls.creatable: 

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

2979 f'{url_prefix}/new') 

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

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

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

2983 config.add_wutta_permission(permission_prefix, 

2984 f'{permission_prefix}.create', 

2985 f"Create new {model_title}") 

2986 

2987 # edit 

2988 if cls.editable: 

2989 instance_url_prefix = cls.get_instance_url_prefix() 

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

2991 f'{instance_url_prefix}/edit') 

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

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

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

2995 config.add_wutta_permission(permission_prefix, 

2996 f'{permission_prefix}.edit', 

2997 f"Edit {model_title}") 

2998 

2999 # delete 

3000 if cls.deletable: 

3001 instance_url_prefix = cls.get_instance_url_prefix() 

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

3003 f'{instance_url_prefix}/delete') 

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

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

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

3007 config.add_wutta_permission(permission_prefix, 

3008 f'{permission_prefix}.delete', 

3009 f"Delete {model_title}") 

3010 

3011 # bulk delete 

3012 if cls.deletable_bulk: 

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

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

3015 request_method='POST') 

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

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

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

3019 config.add_wutta_permission(permission_prefix, 

3020 f'{permission_prefix}.delete_bulk', 

3021 f"Delete {model_title_plural} in bulk") 

3022 

3023 # autocomplete 

3024 if cls.has_autocomplete: 

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

3026 f'{url_prefix}/autocomplete') 

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

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

3029 renderer='json', 

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

3031 

3032 # download 

3033 if cls.downloadable: 

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

3035 f'{instance_url_prefix}/download') 

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

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

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

3039 config.add_wutta_permission(permission_prefix, 

3040 f'{permission_prefix}.download', 

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

3042 

3043 # execute 

3044 if cls.executable: 

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

3046 f'{instance_url_prefix}/execute', 

3047 request_method='POST') 

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

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

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

3051 config.add_wutta_permission(permission_prefix, 

3052 f'{permission_prefix}.execute', 

3053 f"Execute {model_title}") 

3054 

3055 # configure 

3056 if cls.configurable: 

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

3058 f'{url_prefix}/configure') 

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

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

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

3062 config.add_wutta_permission(permission_prefix, 

3063 f'{permission_prefix}.configure', 

3064 f"Configure {model_title_plural}") 

3065 

3066 # view 

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

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

3069 if cls.viewable: 

3070 instance_url_prefix = cls.get_instance_url_prefix() 

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

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

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

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

3075 config.add_wutta_permission(permission_prefix, 

3076 f'{permission_prefix}.view', 

3077 f"View {model_title}")