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

983 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 19:45 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Base Logic for Master Views 

25""" 

26# pylint: disable=too-many-lines 

27 

28import logging 

29import os 

30import threading 

31import warnings 

32 

33import sqlalchemy as sa 

34from sqlalchemy import orm 

35 

36from pyramid.renderers import render_to_response 

37from webhelpers2.html import HTML, tags 

38 

39from wuttjamaican.util import get_class_hierarchy 

40from wuttaweb.views.base import View 

41from wuttaweb.util import get_form_data, render_csrf_token 

42from wuttaweb.db import Session 

43from wuttaweb.progress import SessionProgress 

44from wuttaweb.diffs import VersionDiff 

45 

46 

47log = logging.getLogger(__name__) 

48 

49 

50class MasterView(View): # pylint: disable=too-many-public-methods 

51 """ 

52 Base class for "master" views. 

53 

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

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

56 data record. 

57 

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

59 define :attr:`model_class`:: 

60 

61 from wuttaweb.views import MasterView 

62 from wuttjamaican.db.model import Person 

63 

64 class MyPersonView(MasterView): 

65 model_class = Person 

66 

67 def includeme(config): 

68 MyPersonView.defaults(config) 

69 

70 .. note:: 

71 

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

73 explicitly defined in a subclass. There are corresponding 

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

75 these attributes directly. 

76 

77 .. attribute:: model_class 

78 

79 Optional reference to a :term:`data model` class. While not 

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

81 mapped class, 

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

83 

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

85 :meth:`get_model_class()`. 

86 

87 .. attribute:: model_name 

88 

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

90 e.g. ``'WuttaWidget'``. 

91 

92 Code should not access this directly but instead call 

93 :meth:`get_model_name()`. 

94 

95 .. attribute:: model_name_normalized 

96 

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

98 e.g. ``'wutta_widget'``. 

99 

100 Code should not access this directly but instead call 

101 :meth:`get_model_name_normalized()`. 

102 

103 .. attribute:: model_title 

104 

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

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

107 

108 Code should not access this directly but instead call 

109 :meth:`get_model_title()`. 

110 

111 .. attribute:: model_title_plural 

112 

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

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

115 

116 Code should not access this directly but instead call 

117 :meth:`get_model_title_plural()`. 

118 

119 .. attribute:: model_key 

120 

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

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

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

124 

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

126 model key can be determined automatically. 

127 

128 Code should not access this directly but instead call 

129 :meth:`get_model_key()`. 

130 

131 .. attribute:: grid_key 

132 

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

134 

135 Code should not access this directly but instead call 

136 :meth:`get_grid_key()`. 

137 

138 .. attribute:: config_title 

139 

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

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

142 

143 Code should not access this directly but instead call 

144 :meth:`get_config_title()`. 

145 

146 .. attribute:: route_prefix 

147 

148 Optional override for the view's route prefix, 

149 e.g. ``'wutta_widgets'``. 

150 

151 Code should not access this directly but instead call 

152 :meth:`get_route_prefix()`. 

153 

154 .. attribute:: permission_prefix 

155 

156 Optional override for the view's permission prefix, 

157 e.g. ``'wutta_widgets'``. 

158 

159 Code should not access this directly but instead call 

160 :meth:`get_permission_prefix()`. 

161 

162 .. attribute:: url_prefix 

163 

164 Optional override for the view's URL prefix, 

165 e.g. ``'/widgets'``. 

166 

167 Code should not access this directly but instead call 

168 :meth:`get_url_prefix()`. 

169 

170 .. attribute:: template_prefix 

171 

172 Optional override for the view's template prefix, 

173 e.g. ``'/widgets'``. 

174 

175 Code should not access this directly but instead call 

176 :meth:`get_template_prefix()`. 

177 

178 .. attribute:: listable 

179 

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

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

182 ``True``. 

183 

184 .. attribute:: has_grid 

185 

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

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

188 

189 .. attribute:: grid_columns 

190 

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

192 

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

194 

195 .. attribute:: checkable 

196 

197 Boolean indicating whether the grid should expose per-row 

198 checkboxes. This is passed along to set 

199 :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid. 

200 

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

202 

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

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

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

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

207 

208 For more info see 

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

210 

211 .. attribute:: filterable 

212 

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

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

215 

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

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

218 

219 .. attribute:: filter_defaults 

220 

221 Optional dict of default filter state. 

222 

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

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

225 

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

227 

228 .. attribute:: sortable 

229 

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

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

232 

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

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

235 

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

237 

238 .. attribute:: sort_on_backend 

239 

240 Boolean indicating whether the grid data for the 

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

242 is ``True``. 

243 

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

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

246 

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

248 

249 .. attribute:: sort_defaults 

250 

251 Optional list of default sorting info. Applicable for both 

252 frontend and backend sorting. 

253 

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

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

256 

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

258 

259 .. attribute:: paginated 

260 

261 Boolean indicating whether the grid data for the 

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

263 

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

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

266 

267 .. attribute:: paginate_on_backend 

268 

269 Boolean indicating whether the grid data for the 

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

271 Default is ``True``. 

272 

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

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

275 

276 .. attribute:: creatable 

277 

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

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

280 ``True``. 

281 

282 .. attribute:: viewable 

283 

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

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

286 ``True``. 

287 

288 .. attribute:: editable 

289 

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

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

292 ``True``. 

293 

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

295 

296 .. attribute:: deletable 

297 

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

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

300 ``True``. 

301 

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

303 

304 .. attribute:: deletable_bulk 

305 

306 Boolean indicating whether the view model supports "bulk 

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

308 Default value is ``False``. 

309 

310 See also :attr:`deletable_bulk_quick`. 

311 

312 .. attribute:: deletable_bulk_quick 

313 

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

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

316 should happen *synchronously* with no progress indicator. 

317 

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

319 shown while the bulk deletion is performed. 

320 

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

322 

323 .. attribute:: form_fields 

324 

325 List of fields for the model form. 

326 

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

328 

329 .. attribute:: has_autocomplete 

330 

331 Boolean indicating whether the view model supports 

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

333 view. Default is ``False``. 

334 

335 .. attribute:: downloadable 

336 

337 Boolean indicating whether the view model supports 

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

339 Default is ``False``. 

340 

341 .. attribute:: executable 

342 

343 Boolean indicating whether the view model supports "executing" 

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

345 ``False``. 

346 

347 .. attribute:: configurable 

348 

349 Boolean indicating whether the master view supports 

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

351 Default value is ``False``. 

352 

353 .. attribute:: version_grid_columns 

354 

355 List of columns for the :meth:`view_versions()` view grid. 

356 

357 This is optional; see also :meth:`get_version_grid_columns()`. 

358 

359 **ROW FEATURES** 

360 

361 .. attribute:: has_rows 

362 

363 Whether the model has "child rows" which should also be 

364 displayed when viewing model records. For instance when 

365 viewing a :term:`batch` you want to see both the batch header 

366 as well as its row data. 

367 

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

369 on then many other things kick in. 

370 

371 See also :attr:`row_model_class`. 

372 

373 .. attribute:: row_model_class 

374 

375 Reference to the :term:`data model` class for the child rows. 

376 

377 Subclass should define this if :attr:`has_rows` is true. 

378 

379 View logic should not access this directly but instead call 

380 :meth:`get_row_model_class()`. 

381 

382 .. attribute:: row_model_name 

383 

384 Optional override for the view's row model name, 

385 e.g. ``'WuttaWidget'``. 

386 

387 Code should not access this directly but instead call 

388 :meth:`get_row_model_name()`. 

389 

390 .. attribute:: row_model_title 

391 

392 Optional override for the view's "humanized" (singular) row 

393 model title, e.g. ``"Wutta Widget"``. 

394 

395 Code should not access this directly but instead call 

396 :meth:`get_row_model_title()`. 

397 

398 .. attribute:: row_model_title_plural 

399 

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

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

402 

403 Code should not access this directly but instead call 

404 :meth:`get_row_model_title_plural()`. 

405 

406 .. attribute:: rows_title 

407 

408 Display title for the rows grid. 

409 

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

411 :meth:`get_rows_title()`. 

412 

413 .. attribute:: row_grid_columns 

414 

415 List of columns for the row grid. 

416 

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

418 

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

420 

421 .. attribute:: rows_viewable 

422 

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

424 i.e. the row grid should have a "View" action. Default value 

425 is ``False``. 

426 

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

428 :meth:`get_row_action_url_view()`. 

429 

430 .. note:: 

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

432 to be configured as well. 

433 

434 .. attribute:: row_form_fields 

435 

436 List of fields for the row model form. 

437 

438 This is optional; see also :meth:`get_row_form_fields()`. 

439 

440 .. attribute:: rows_creatable 

441 

442 Boolean indicating whether the row model supports "creating" - 

443 i.e. a route should be defined for :meth:`create_row()`. 

444 Default value is ``False``. 

445 """ 

446 

447 ############################## 

448 # attributes 

449 ############################## 

450 

451 model_class = None 

452 

453 # features 

454 listable = True 

455 has_grid = True 

456 checkable = False 

457 filterable = True 

458 filter_defaults = None 

459 sortable = True 

460 sort_on_backend = True 

461 sort_defaults = None 

462 paginated = True 

463 paginate_on_backend = True 

464 creatable = True 

465 viewable = True 

466 editable = True 

467 deletable = True 

468 deletable_bulk = False 

469 deletable_bulk_quick = False 

470 has_autocomplete = False 

471 downloadable = False 

472 executable = False 

473 execute_progress_template = None 

474 configurable = False 

475 

476 # row features 

477 has_rows = False 

478 row_model_class = None 

479 rows_filterable = True 

480 rows_filter_defaults = None 

481 rows_sortable = True 

482 rows_sort_on_backend = True 

483 rows_sort_defaults = None 

484 rows_paginated = True 

485 rows_paginate_on_backend = True 

486 rows_viewable = False 

487 rows_creatable = False 

488 

489 # current action 

490 listing = False 

491 creating = False 

492 viewing = False 

493 editing = False 

494 deleting = False 

495 executing = False 

496 configuring = False 

497 

498 # default DB session 

499 Session = Session 

500 

501 ############################## 

502 # index methods 

503 ############################## 

504 

505 def index(self): 

506 """ 

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

508 

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

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

511 e.g. ``/widgets/``. 

512 

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

514 true. 

515 

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

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

518 

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

520 

521 * :meth:`make_model_grid()` 

522 """ 

523 self.listing = True 

524 

525 context = { 

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

527 } 

528 

529 if self.has_grid: 

530 grid = self.make_model_grid() 

531 

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

533 if self.request.GET.get("partial"): 

534 

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

536 context = grid.get_vue_context() 

537 if grid.paginated and grid.paginate_on_backend: 

538 context["pager_stats"] = grid.get_vue_pager_stats() 

539 return self.json_response(context) 

540 

541 # full, not partial 

542 

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

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

545 if self.request.GET.get("reset-view"): 

546 

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

548 kw = {"_query": None, "_anchor": self.request.GET.get("hash")} 

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

550 

551 context["grid"] = grid 

552 

553 return self.render_to_response("index", context) 

554 

555 ############################## 

556 # create methods 

557 ############################## 

558 

559 def create(self): 

560 """ 

561 View to "create" a new model record. 

562 

563 This usually corresponds to URL like ``/widgets/new`` 

564 

565 By default, this route is included only if :attr:`creatable` 

566 is true. 

567 

568 The default logic calls :meth:`make_create_form()` and shows 

569 that to the user. When they submit valid data, it calls 

570 :meth:`save_create_form()` and then 

571 :meth:`redirect_after_create()`. 

572 """ 

573 self.creating = True 

574 form = self.make_create_form() 

575 

576 if form.validate(): 

577 session = self.Session() 

578 try: 

579 result = self.save_create_form(form) 

580 # nb. must always flush to ensure primary key is set 

581 session.flush() 

582 except Exception as err: # pylint: disable=broad-exception-caught 

583 log.warning("failed to save 'create' form", exc_info=True) 

584 self.request.session.flash(f"Create failed: {err}", "error") 

585 else: 

586 return self.redirect_after_create(result) 

587 

588 context = {"form": form} 

589 return self.render_to_response("create", context) 

590 

591 def make_create_form(self): 

592 """ 

593 Make the "create" model form. This is called by 

594 :meth:`create()`. 

595 

596 Default logic calls :meth:`make_model_form()`. 

597 

598 :returns: :class:`~wuttaweb.forms.base.Form` instance 

599 """ 

600 return self.make_model_form(cancel_url_fallback=self.get_index_url()) 

601 

602 def save_create_form(self, form): 

603 """ 

604 Save the "create" form. This is called by :meth:`create()`. 

605 

606 Default logic calls :meth:`objectify()` and then 

607 :meth:`persist()`. Subclass is expected to override for 

608 non-standard use cases. 

609 

610 As for return value, by default it will be whatever came back 

611 from the ``objectify()`` call. In practice a subclass can 

612 return whatever it likes. The value is only used as input to 

613 :meth:`redirect_after_create()`. 

614 

615 :returns: Usually the model instance, but can be "anything" 

616 """ 

617 if hasattr(self, "create_save_form"): # pragma: no cover 

618 warnings.warn( 

619 "MasterView.create_save_form() method name is deprecated; " 

620 f"please refactor to save_create_form() instead for {self.__class__.__name__}", 

621 DeprecationWarning, 

622 ) 

623 return self.create_save_form(form) 

624 

625 obj = self.objectify(form) 

626 self.persist(obj) 

627 return obj 

628 

629 def redirect_after_create(self, result): 

630 """ 

631 Must return a redirect, following successful save of the 

632 "create" form. This is called by :meth:`create()`. 

633 

634 By default this redirects to the "view" page for the new 

635 record. 

636 

637 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance 

638 """ 

639 return self.redirect(self.get_action_url("view", result)) 

640 

641 ############################## 

642 # view methods 

643 ############################## 

644 

645 def view(self): 

646 """ 

647 View to "view" a model record. 

648 

649 This usually corresponds to URL like ``/widgets/XXX`` 

650 

651 By default, this route is included only if :attr:`viewable` is 

652 true. 

653 

654 The default logic here is as follows: 

655 

656 First, if :attr:`has_rows` is true then 

657 :meth:`make_row_model_grid()` is called. 

658 

659 If ``has_rows`` is true *and* the request has certain special 

660 params relating to the grid, control may exit early. Mainly 

661 this happens when a "partial" page is requested, which means 

662 we just return grid data and nothing else. (Used for backend 

663 sorting and pagination etc.) 

664 

665 Otherwise :meth:`make_view_form()` is called, and the template 

666 is rendered. 

667 """ 

668 self.viewing = True 

669 obj = self.get_instance() 

670 context = {"instance": obj} 

671 

672 if self.has_rows: 

673 

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

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

676 grid = self.make_row_model_grid(obj) 

677 

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

679 # redirect so the query string gets cleared out 

680 if self.request.GET.get("reset-view"): 

681 

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

683 kw = {"_query": None, "_anchor": self.request.GET.get("hash")} 

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

685 

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

687 if self.request.params.get("partial"): 

688 context = grid.get_vue_context() 

689 if grid.paginated and grid.paginate_on_backend: 

690 context["pager_stats"] = grid.get_vue_pager_stats() 

691 return self.json_response(context) 

692 

693 context["rows_grid"] = grid 

694 

695 context["form"] = self.make_view_form(obj) 

696 context["xref_buttons"] = self.get_xref_buttons(obj) 

697 return self.render_to_response("view", context) 

698 

699 def make_view_form(self, obj, readonly=True): 

700 """ 

701 Make the "view" model form. This is called by 

702 :meth:`view()`. 

703 

704 Default logic calls :meth:`make_model_form()`. 

705 

706 :returns: :class:`~wuttaweb.forms.base.Form` instance 

707 """ 

708 return self.make_model_form(obj, readonly=readonly) 

709 

710 ############################## 

711 # edit methods 

712 ############################## 

713 

714 def edit(self): 

715 """ 

716 View to "edit" a model record. 

717 

718 This usually corresponds to URL like ``/widgets/XXX/edit`` 

719 

720 By default, this route is included only if :attr:`editable` is 

721 true. 

722 

723 The default logic calls :meth:`make_edit_form()` and shows 

724 that to the user. When they submit valid data, it calls 

725 :meth:`save_edit_form()` and then 

726 :meth:`redirect_after_edit()`. 

727 """ 

728 self.editing = True 

729 instance = self.get_instance() 

730 form = self.make_edit_form(instance) 

731 

732 if form.validate(): 

733 try: 

734 result = self.save_edit_form(form) 

735 except Exception as err: # pylint: disable=broad-exception-caught 

736 log.warning("failed to save 'edit' form", exc_info=True) 

737 self.request.session.flash(f"Edit failed: {err}", "error") 

738 else: 

739 return self.redirect_after_edit(result) 

740 

741 context = { 

742 "instance": instance, 

743 "form": form, 

744 } 

745 return self.render_to_response("edit", context) 

746 

747 def make_edit_form(self, obj): 

748 """ 

749 Make the "edit" model form. This is called by 

750 :meth:`edit()`. 

751 

752 Default logic calls :meth:`make_model_form()`. 

753 

754 :returns: :class:`~wuttaweb.forms.base.Form` instance 

755 """ 

756 return self.make_model_form( 

757 obj, cancel_url_fallback=self.get_action_url("view", obj) 

758 ) 

759 

760 def save_edit_form(self, form): 

761 """ 

762 Save the "edit" form. This is called by :meth:`edit()`. 

763 

764 Default logic calls :meth:`objectify()` and then 

765 :meth:`persist()`. Subclass is expected to override for 

766 non-standard use cases. 

767 

768 As for return value, by default it will be whatever came back 

769 from the ``objectify()`` call. In practice a subclass can 

770 return whatever it likes. The value is only used as input to 

771 :meth:`redirect_after_edit()`. 

772 

773 :returns: Usually the model instance, but can be "anything" 

774 """ 

775 if hasattr(self, "edit_save_form"): # pragma: no cover 

776 warnings.warn( 

777 "MasterView.edit_save_form() method name is deprecated; " 

778 f"please refactor to save_edit_form() instead for {self.__class__.__name__}", 

779 DeprecationWarning, 

780 ) 

781 return self.edit_save_form(form) 

782 

783 obj = self.objectify(form) 

784 self.persist(obj) 

785 return obj 

786 

787 def redirect_after_edit(self, result): 

788 """ 

789 Must return a redirect, following successful save of the 

790 "edit" form. This is called by :meth:`edit()`. 

791 

792 By default this redirects to the "view" page for the record. 

793 

794 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance 

795 """ 

796 return self.redirect(self.get_action_url("view", result)) 

797 

798 ############################## 

799 # delete methods 

800 ############################## 

801 

802 def delete(self): 

803 """ 

804 View to "delete" a model record. 

805 

806 This usually corresponds to URL like ``/widgets/XXX/delete`` 

807 

808 By default, this route is included only if :attr:`deletable` 

809 is true. 

810 

811 The default logic calls :meth:`make_delete_form()` and shows 

812 that to the user. When they submit, it calls 

813 :meth:`save_delete_form()` and then 

814 :meth:`redirect_after_delete()`. 

815 """ 

816 self.deleting = True 

817 instance = self.get_instance() 

818 

819 if not self.is_deletable(instance): 

820 return self.redirect(self.get_action_url("view", instance)) 

821 

822 form = self.make_delete_form(instance) 

823 

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

825 if form.validate() is not False: 

826 

827 try: 

828 result = self.save_delete_form( # pylint: disable=assignment-from-none 

829 form 

830 ) 

831 except Exception as err: # pylint: disable=broad-exception-caught 

832 log.warning("failed to save 'delete' form", exc_info=True) 

833 self.request.session.flash(f"Delete failed: {err}", "error") 

834 else: 

835 return self.redirect_after_delete(result) 

836 

837 context = { 

838 "instance": instance, 

839 "form": form, 

840 } 

841 return self.render_to_response("delete", context) 

842 

843 def make_delete_form(self, obj): 

844 """ 

845 Make the "delete" model form. This is called by 

846 :meth:`delete()`. 

847 

848 Default logic calls :meth:`make_model_form()` but with a 

849 twist: 

850 

851 The form proper is *not* readonly; this ensures the form has a 

852 submit button etc. But then all fields in the form are 

853 explicitly marked readonly. 

854 

855 :returns: :class:`~wuttaweb.forms.base.Form` instance 

856 """ 

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

858 form = self.make_model_form( 

859 obj, 

860 cancel_url_fallback=self.get_action_url("view", obj), 

861 button_label_submit="DELETE Forever", 

862 button_icon_submit="trash", 

863 button_type_submit="is-danger", 

864 ) 

865 

866 # ..but *all* fields are readonly 

867 form.readonly_fields = set(form.fields) 

868 return form 

869 

870 def save_delete_form(self, form): 

871 """ 

872 Save the "delete" form. This is called by :meth:`delete()`. 

873 

874 Default logic calls :meth:`delete_instance()`. Normally 

875 subclass would override that for non-standard use cases, but 

876 it could also/instead override this method. 

877 

878 As for return value, by default this returns ``None``. In 

879 practice a subclass can return whatever it likes. The value 

880 is only used as input to :meth:`redirect_after_delete()`. 

881 

882 :returns: Usually ``None``, but can be "anything" 

883 """ 

884 if hasattr(self, "delete_save_form"): # pragma: no cover 

885 warnings.warn( 

886 "MasterView.delete_save_form() method name is deprecated; " 

887 f"please refactor to save_delete_form() instead for {self.__class__.__name__}", 

888 DeprecationWarning, 

889 ) 

890 self.delete_save_form(form) 

891 return 

892 

893 obj = form.model_instance 

894 self.delete_instance(obj) 

895 

896 def redirect_after_delete(self, result): # pylint: disable=unused-argument 

897 """ 

898 Must return a redirect, following successful save of the 

899 "delete" form. This is called by :meth:`delete()`. 

900 

901 By default this redirects back to the :meth:`index()` page. 

902 

903 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance 

904 """ 

905 return self.redirect(self.get_index_url()) 

906 

907 def delete_instance(self, obj): 

908 """ 

909 Delete the given model instance. 

910 

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

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

913 needed. 

914 

915 This method is called by :meth:`save_delete_form()`. 

916 """ 

917 session = self.app.get_session(obj) 

918 session.delete(obj) 

919 

920 def delete_bulk(self): 

921 """ 

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

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

924 

925 This usually corresponds to a URL like 

926 ``/widgets/delete-bulk``. 

927 

928 By default, this view is included only if 

929 :attr:`deletable_bulk` is true. 

930 

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

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

933 

934 Subclass normally should not override this method, but rather 

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

936 this one: 

937 

938 * :meth:`delete_bulk_action()` 

939 """ 

940 

941 # get current data set from grid 

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

943 grid = self.make_model_grid(paginated=False) 

944 data = grid.get_visible_data() 

945 

946 if self.deletable_bulk_quick: 

947 

948 # delete it all and go back to listing 

949 self.delete_bulk_action(data) 

950 return self.redirect(self.get_index_url()) 

951 

952 # start thread for delete; show progress page 

953 route_prefix = self.get_route_prefix() 

954 key = f"{route_prefix}.delete_bulk" 

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

956 thread = threading.Thread( 

957 target=self.delete_bulk_thread, 

958 args=(data,), 

959 kwargs={"progress": progress}, 

960 ) 

961 thread.start() 

962 return self.render_progress(progress) 

963 

964 def delete_bulk_thread( # pylint: disable=empty-docstring 

965 self, query, progress=None 

966 ): 

967 """ """ 

968 session = self.app.make_session() 

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

970 

971 def onerror(): 

972 log.warning( 

973 "failed to delete %s results for %s", 

974 len(records), 

975 self.get_model_title_plural(), 

976 exc_info=True, 

977 ) 

978 

979 self.do_thread_body( 

980 self.delete_bulk_action, 

981 (records,), 

982 {"progress": progress}, 

983 onerror, 

984 session=session, 

985 progress=progress, 

986 ) 

987 

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

989 """ 

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

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

992 

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

994 record, and if that returns true then it calls 

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

996 updated if one is provided. 

997 

998 Subclass should override if needed. 

999 """ 

1000 model_title_plural = self.get_model_title_plural() 

1001 

1002 def delete(obj, i): # pylint: disable=unused-argument 

1003 if self.is_deletable(obj): 

1004 self.delete_instance(obj) 

1005 

1006 self.app.progress_loop( 

1007 delete, data, progress, message=f"Deleting {model_title_plural}" 

1008 ) 

1009 

1010 def delete_bulk_make_button(self): # pylint: disable=empty-docstring 

1011 """ """ 

1012 route_prefix = self.get_route_prefix() 

1013 

1014 label = HTML.literal( 

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

1016 ) 

1017 button = self.make_button( 

1018 label, 

1019 variant="is-danger", 

1020 icon_left="trash", 

1021 **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"}, 

1022 ) 

1023 

1024 form = HTML.tag( 

1025 "form", 

1026 method="post", 

1027 action=self.request.route_url(f"{route_prefix}.delete_bulk"), 

1028 ref="deleteResultsForm", 

1029 class_="control", 

1030 c=[ 

1031 render_csrf_token(self.request), 

1032 button, 

1033 ], 

1034 ) 

1035 return form 

1036 

1037 ############################## 

1038 # version history methods 

1039 ############################## 

1040 

1041 @classmethod 

1042 def is_versioned(cls): 

1043 """ 

1044 Returns boolean indicating whether the model class is 

1045 configured for SQLAlchemy-Continuum versioning. 

1046 

1047 The default logic will directly inspect the model class, as 

1048 returned by :meth:`get_model_class()`. Or you can override by 

1049 setting the ``model_is_versioned`` attribute:: 

1050 

1051 class WidgetView(MasterView): 

1052 model_class = Widget 

1053 model_is_versioned = False 

1054 

1055 See also :meth:`should_expose_versions()`. 

1056 

1057 :returns: ``True`` if the model class is versioned; else 

1058 ``False``. 

1059 """ 

1060 if hasattr(cls, "model_is_versioned"): 

1061 return cls.model_is_versioned 

1062 

1063 model_class = cls.get_model_class() 

1064 if hasattr(model_class, "__versioned__"): 

1065 return True 

1066 

1067 return False 

1068 

1069 @classmethod 

1070 def get_model_version_class(cls): 

1071 """ 

1072 Returns the version class for the master model class. 

1073 

1074 Should only be relevant if :meth:`is_versioned()` is true. 

1075 """ 

1076 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

1077 

1078 return continuum.version_class(cls.get_model_class()) 

1079 

1080 def should_expose_versions(self): 

1081 """ 

1082 Returns boolean indicating whether versioning history should 

1083 be exposed for the current user. This will return ``True`` 

1084 unless any of the following are ``False``: 

1085 

1086 * :meth:`is_versioned()` 

1087 * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()` 

1088 * ``self.has_perm("versions")`` - cf. :meth:`has_perm()` 

1089 

1090 :returns: ``True`` if versioning should be exposed for current 

1091 user; else ``False``. 

1092 """ 

1093 if not self.is_versioned(): 

1094 return False 

1095 

1096 if not self.app.continuum_is_enabled(): 

1097 return False 

1098 

1099 if not self.has_perm("versions"): 

1100 return False 

1101 

1102 return True 

1103 

1104 def view_versions(self): 

1105 """ 

1106 View to list version history for an object. See also 

1107 :meth:`view_version()`. 

1108 

1109 This usually corresponds to a URL like 

1110 ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID 

1111 for the record. 

1112 

1113 By default, this view is included only if 

1114 :meth:`is_versioned()` is true. 

1115 

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

1117 record's version history. 

1118 

1119 See also: 

1120 

1121 * :meth:`make_version_grid()` 

1122 """ 

1123 instance = self.get_instance() 

1124 instance_title = self.get_instance_title(instance) 

1125 grid = self.make_version_grid(instance) 

1126 

1127 # return grid data only, if partial page was requested 

1128 if self.request.GET.get("partial"): 

1129 context = grid.get_vue_context() 

1130 if grid.paginated and grid.paginate_on_backend: 

1131 context["pager_stats"] = grid.get_vue_pager_stats() 

1132 return self.json_response(context) 

1133 

1134 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

1135 

1136 instance_link = tags.link_to( 

1137 instance_title, self.get_action_url("view", instance) 

1138 ) 

1139 

1140 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

1141 [index_link, instance_link] 

1142 ) 

1143 

1144 return self.render_to_response( 

1145 "view_versions", 

1146 { 

1147 "index_title_rendered": index_title_rendered, 

1148 "instance": instance, 

1149 "instance_title": instance_title, 

1150 "instance_url": self.get_action_url("view", instance), 

1151 "grid": grid, 

1152 }, 

1153 ) 

1154 

1155 def make_version_grid(self, instance=None, **kwargs): 

1156 """ 

1157 Create and return a grid for use with the 

1158 :meth:`view_versions()` view. 

1159 

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

1161 

1162 * :meth:`get_version_grid_key()` 

1163 * :meth:`get_version_grid_columns()` 

1164 * :meth:`get_version_grid_data()` 

1165 * :meth:`configure_version_grid()` 

1166 

1167 :returns: :class:`~wuttaweb.grids.base.Grid` instance 

1168 """ 

1169 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

1170 

1171 route_prefix = self.get_route_prefix() 

1172 # instance = kwargs.pop("instance", None) 

1173 if not instance: 

1174 instance = self.get_instance() 

1175 

1176 if "key" not in kwargs: 

1177 kwargs["key"] = self.get_version_grid_key() 

1178 

1179 if "model_class" not in kwargs: 

1180 kwargs["model_class"] = continuum.transaction_class(self.get_model_class()) 

1181 

1182 if "columns" not in kwargs: 

1183 kwargs["columns"] = self.get_version_grid_columns() 

1184 

1185 if "data" not in kwargs: 

1186 kwargs["data"] = self.get_version_grid_data(instance) 

1187 

1188 if "actions" not in kwargs: 

1189 route = f"{route_prefix}.version" 

1190 

1191 def url(txn, i): # pylint: disable=unused-argument 

1192 return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) 

1193 

1194 kwargs["actions"] = [ 

1195 self.make_grid_action("view", icon="eye", url=url), 

1196 ] 

1197 

1198 kwargs.setdefault("paginated", True) 

1199 

1200 grid = self.make_grid(**kwargs) 

1201 self.configure_version_grid(grid) 

1202 grid.load_settings() 

1203 return grid 

1204 

1205 @classmethod 

1206 def get_version_grid_key(cls): 

1207 """ 

1208 Returns the unique key to be used for the version grid, for caching 

1209 sort/filter options etc. 

1210 

1211 This is normally called automatically from :meth:`make_version_grid()`. 

1212 

1213 :returns: Grid key as string 

1214 """ 

1215 if hasattr(cls, "version_grid_key"): 

1216 return cls.version_grid_key 

1217 return f"{cls.get_route_prefix()}.history" 

1218 

1219 def get_version_grid_columns(self): 

1220 """ 

1221 Returns the default list of version grid column names, for the 

1222 :meth:`view_versions()` view. 

1223 

1224 This is normally called automatically by 

1225 :meth:`make_version_grid()`. 

1226 

1227 Subclass may define :attr:`version_grid_columns` for simple 

1228 cases, or can override this method if needed. 

1229 

1230 :returns: List of string column names 

1231 """ 

1232 if hasattr(self, "version_grid_columns"): 

1233 return self.version_grid_columns 

1234 

1235 return [ 

1236 "id", 

1237 "issued_at", 

1238 "user", 

1239 "remote_addr", 

1240 "comment", 

1241 ] 

1242 

1243 def get_version_grid_data(self, instance): 

1244 """ 

1245 Returns the grid data query for the :meth:`view_versions()` 

1246 view. 

1247 

1248 This is normally called automatically by 

1249 :meth:`make_version_grid()`. 

1250 

1251 Default query will locate SQLAlchemy-Continuum ``transaction`` 

1252 records which are associated with versions of the given model 

1253 instance. See also 

1254 :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`. 

1255 

1256 :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance 

1257 """ 

1258 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

1259 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel 

1260 model_transaction_query, 

1261 ) 

1262 

1263 model_class = self.get_model_class() 

1264 txncls = continuum.transaction_class(model_class) 

1265 query = model_transaction_query(instance) 

1266 return query.order_by(txncls.issued_at.desc()) 

1267 

1268 def configure_version_grid(self, g): 

1269 """ 

1270 Configure the grid for the :meth:`view_versions()` view. 

1271 

1272 This is called automatically by :meth:`make_version_grid()`. 

1273 

1274 Default logic applies basic customization to the column labels etc. 

1275 """ 

1276 # id 

1277 g.set_label("id", "TXN ID") 

1278 # g.set_link("id") 

1279 

1280 # issued_at 

1281 g.set_label("issued_at", "Changed") 

1282 g.set_link("issued_at") 

1283 g.set_sort_defaults("issued_at", "desc") 

1284 

1285 # user 

1286 g.set_label("user", "Changed by") 

1287 g.set_link("user") 

1288 

1289 # remote_addr 

1290 g.set_label("remote_addr", "IP Address") 

1291 

1292 # comment 

1293 g.set_renderer("comment", self.render_version_comment) 

1294 

1295 def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument 

1296 self, txn, key, value 

1297 ): 

1298 return txn.meta.get("comment", "") 

1299 

1300 def view_version(self): # pylint: disable=too-many-locals 

1301 """ 

1302 View to show diff details for a particular object version. 

1303 See also :meth:`view_versions()`. 

1304 

1305 This usually corresponds to a URL like 

1306 ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the 

1307 key/ID for the record and YYY represents a 

1308 SQLAlchemy-Continuum ``transaction.id``. 

1309 

1310 By default, this view is included only if 

1311 :meth:`is_versioned()` is true. 

1312 

1313 The default view logic will display a "diff" table showing how 

1314 the record's values were changed within a transaction. 

1315 

1316 See also: 

1317 

1318 * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()` 

1319 * :meth:`get_relevant_versions()` 

1320 * :class:`~wuttaweb.diffs.VersionDiff` 

1321 """ 

1322 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

1323 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel 

1324 model_transaction_query, 

1325 ) 

1326 

1327 instance = self.get_instance() 

1328 model_class = self.get_model_class() 

1329 route_prefix = self.get_route_prefix() 

1330 txncls = continuum.transaction_class(model_class) 

1331 transactions = model_transaction_query(instance) 

1332 

1333 txnid = self.request.matchdict["txnid"] 

1334 txn = transactions.filter(txncls.id == txnid).first() 

1335 if not txn: 

1336 raise self.notfound() 

1337 

1338 prev_url = None 

1339 older = ( 

1340 transactions.filter(txncls.issued_at <= txn.issued_at) 

1341 .filter(txncls.id != txnid) 

1342 .order_by(txncls.issued_at.desc()) 

1343 .first() 

1344 ) 

1345 if older: 

1346 prev_url = self.request.route_url( 

1347 f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id 

1348 ) 

1349 

1350 next_url = None 

1351 newer = ( 

1352 transactions.filter(txncls.issued_at >= txn.issued_at) 

1353 .filter(txncls.id != txnid) 

1354 .order_by(txncls.issued_at) 

1355 .first() 

1356 ) 

1357 if newer: 

1358 next_url = self.request.route_url( 

1359 f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id 

1360 ) 

1361 

1362 version_diffs = [ 

1363 VersionDiff(self.config, version) 

1364 for version in self.get_relevant_versions(txn, instance) 

1365 ] 

1366 

1367 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

1368 

1369 instance_title = self.get_instance_title(instance) 

1370 instance_link = tags.link_to( 

1371 instance_title, self.get_action_url("view", instance) 

1372 ) 

1373 

1374 history_link = tags.link_to( 

1375 "history", 

1376 self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid), 

1377 ) 

1378 

1379 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

1380 [index_link, instance_link, history_link] 

1381 ) 

1382 

1383 return self.render_to_response( 

1384 "view_version", 

1385 { 

1386 "index_title_rendered": index_title_rendered, 

1387 "instance": instance, 

1388 "instance_title": instance_title, 

1389 "instance_url": self.get_action_url("versions", instance), 

1390 "transaction": txn, 

1391 "changed": self.app.render_datetime(txn.issued_at, html=True), 

1392 "version_diffs": version_diffs, 

1393 "show_prev_next": True, 

1394 "prev_url": prev_url, 

1395 "next_url": next_url, 

1396 }, 

1397 ) 

1398 

1399 def get_relevant_versions(self, transaction, instance): 

1400 """ 

1401 Should return all version records pertaining to the given 

1402 model instance and transaction. 

1403 

1404 This is normally called from :meth:`view_version()`. 

1405 

1406 :param transaction: SQLAlchemy-Continuum ``transaction`` 

1407 record/instance. 

1408 

1409 :param instance: Instance of the model class. 

1410 

1411 :returns: List of version records. 

1412 """ 

1413 session = self.Session() 

1414 vercls = self.get_model_version_class() 

1415 return ( 

1416 session.query(vercls) 

1417 .filter(vercls.transaction == transaction) 

1418 .filter(vercls.uuid == instance.uuid) 

1419 .all() 

1420 ) 

1421 

1422 ############################## 

1423 # autocomplete methods 

1424 ############################## 

1425 

1426 def autocomplete(self): 

1427 """ 

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

1429 list of autocomplete results to match. 

1430 

1431 By default, this view is included only if 

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

1433 like ``/widgets/autocomplete``. 

1434 

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

1436 rather should override the others which this calls: 

1437 

1438 * :meth:`autocomplete_data()` 

1439 * :meth:`autocomplete_normalize()` 

1440 """ 

1441 term = self.request.GET.get("term", "") 

1442 if not term: 

1443 return [] 

1444 

1445 data = self.autocomplete_data(term) # pylint: disable=assignment-from-none 

1446 if not data: 

1447 return [] 

1448 

1449 max_results = 100 # TODO 

1450 

1451 results = [] 

1452 for obj in data[:max_results]: 

1453 normal = self.autocomplete_normalize(obj) 

1454 if normal: 

1455 results.append(normal) 

1456 

1457 return results 

1458 

1459 def autocomplete_data(self, term): # pylint: disable=unused-argument 

1460 """ 

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

1462 based on autocomplete search term. This is called by 

1463 :meth:`autocomplete()`. 

1464 

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

1466 

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

1468 

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

1470 """ 

1471 return None 

1472 

1473 def autocomplete_normalize(self, obj): 

1474 """ 

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

1476 record, suitable for autocomplete JSON results. This is 

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

1478 

1479 Subclass may need to override this; default logic is 

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

1481 "autocomplete results" dict for the object:: 

1482 

1483 { 

1484 'value': obj.uuid, 

1485 'label': str(obj), 

1486 } 

1487 

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

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

1490 

1491 :param obj: Model record/instance. 

1492 

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

1494 above. 

1495 """ 

1496 return { 

1497 "value": obj.uuid, 

1498 "label": str(obj), 

1499 } 

1500 

1501 ############################## 

1502 # download methods 

1503 ############################## 

1504 

1505 def download(self): 

1506 """ 

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

1508 

1509 This usually corresponds to a URL like 

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

1511 for the record. 

1512 

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

1514 is true. 

1515 

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

1517 it as a file download response to the client. 

1518 

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

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

1521 files associated with the model record. This filename is 

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

1523 

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

1525 

1526 Subclass normally should not override this method, but rather 

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

1528 this one: 

1529 

1530 * :meth:`download_path()` 

1531 """ 

1532 obj = self.get_instance() 

1533 filename = self.request.GET.get("filename", None) 

1534 

1535 path = self.download_path(obj, filename) # pylint: disable=assignment-from-none 

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

1537 return self.notfound() 

1538 

1539 return self.file_response(path) 

1540 

1541 def download_path(self, obj, filename): # pylint: disable=unused-argument 

1542 """ 

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

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

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

1546 

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

1548 

1549 :param obj: Refefence to the model instance. 

1550 

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

1552 

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

1554 

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

1556 file path should be returned, if applicable. 

1557 

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

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

1560 response. 

1561 """ 

1562 return None 

1563 

1564 ############################## 

1565 # execute methods 

1566 ############################## 

1567 

1568 def execute(self): 

1569 """ 

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

1571 

1572 This usually corresponds to a URL like 

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

1574 for the record. 

1575 

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

1577 true. 

1578 

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

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

1581 

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

1583 * batches (not yet implemented; 

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

1585 Manual) 

1586 

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

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

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

1590 live" the data held within the batch. 

1591 

1592 Subclass normally should not override this method, but rather 

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

1594 this one: 

1595 

1596 * :meth:`execute_instance()` 

1597 """ 

1598 route_prefix = self.get_route_prefix() 

1599 model_title = self.get_model_title() 

1600 obj = self.get_instance() 

1601 

1602 # make the progress tracker 

1603 progress = self.make_progress( 

1604 f"{route_prefix}.execute", 

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

1606 success_url=self.get_action_url("view", obj), 

1607 ) 

1608 

1609 # start thread for execute; show progress page 

1610 key = self.request.matchdict 

1611 thread = threading.Thread( 

1612 target=self.execute_thread, 

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

1614 kwargs={"progress": progress}, 

1615 ) 

1616 thread.start() 

1617 return self.render_progress( 

1618 progress, 

1619 context={ 

1620 "instance": obj, 

1621 }, 

1622 template=self.execute_progress_template, 

1623 ) 

1624 

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

1626 """ 

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

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

1629 

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

1631 

1632 :param obj: Reference to the model instance. 

1633 

1634 :param user: Reference to the 

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

1636 is doing the execute. 

1637 

1638 :param progress: Optional progress indicator factory. 

1639 """ 

1640 

1641 def execute_thread( # pylint: disable=empty-docstring 

1642 self, key, user_uuid, progress=None 

1643 ): 

1644 """ """ 

1645 model = self.app.model 

1646 model_title = self.get_model_title() 

1647 

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

1649 session = self.app.make_session() 

1650 

1651 # fetch model instance and user for this session 

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

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

1654 

1655 try: 

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

1657 

1658 except Exception as error: # pylint: disable=broad-exception-caught 

1659 session.rollback() 

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

1661 if progress: 

1662 progress.handle_error(error) 

1663 

1664 else: 

1665 session.commit() 

1666 if progress: 

1667 progress.handle_success() 

1668 

1669 finally: 

1670 session.close() 

1671 

1672 ############################## 

1673 # configure methods 

1674 ############################## 

1675 

1676 def configure(self, session=None): 

1677 """ 

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

1679 this master view and/or model. 

1680 

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

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

1683 

1684 The expected workflow is as follows: 

1685 

1686 * user navigates to Configure page 

1687 * user modifies settings and clicks Save 

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

1689 * then it saves user-submitted settings 

1690 

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

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

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

1694 

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

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

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

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

1699 

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

1701 only provide their basic definitions via 

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

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

1704 to achieve the aim(s). 

1705 

1706 See also related methods, used by this one: 

1707 

1708 * :meth:`configure_get_simple_settings()` 

1709 * :meth:`configure_get_context()` 

1710 * :meth:`configure_gather_settings()` 

1711 * :meth:`configure_remove_settings()` 

1712 * :meth:`configure_save_settings()` 

1713 """ 

1714 self.configuring = True 

1715 config_title = self.get_config_title() 

1716 

1717 # was form submitted? 

1718 if self.request.method == "POST": 

1719 

1720 # maybe just remove settings 

1721 if self.request.POST.get("remove_settings"): 

1722 self.configure_remove_settings(session=session) 

1723 self.request.session.flash( 

1724 f"All settings for {config_title} have been removed.", "warning" 

1725 ) 

1726 

1727 # reload configure page 

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

1729 

1730 # gather/save settings 

1731 data = get_form_data(self.request) 

1732 settings = self.configure_gather_settings(data) 

1733 self.configure_remove_settings(session=session) 

1734 self.configure_save_settings(settings, session=session) 

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

1736 

1737 # reload configure page 

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

1739 

1740 # render configure page 

1741 context = self.configure_get_context() 

1742 return self.render_to_response("configure", context) 

1743 

1744 def configure_get_context( 

1745 self, 

1746 simple_settings=None, 

1747 ): 

1748 """ 

1749 Returns the full context dict, for rendering the 

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

1751 

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

1753 to just name/value). 

1754 

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

1756 "complex" settings etc. 

1757 

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

1759 already initialized. Otherwise it is retrieved via 

1760 :meth:`configure_get_simple_settings()`. 

1761 

1762 :returns: Context dict for the page template. 

1763 """ 

1764 context = {} 

1765 

1766 # simple settings 

1767 if simple_settings is None: 

1768 simple_settings = self.configure_get_simple_settings() 

1769 if simple_settings: 

1770 

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

1772 normalized = {} 

1773 for simple in simple_settings: 

1774 

1775 # name 

1776 name = simple["name"] 

1777 

1778 # value 

1779 if "value" in simple: 

1780 value = simple["value"] 

1781 elif simple.get("type") is bool: 

1782 value = self.config.get_bool( 

1783 name, default=simple.get("default", False) 

1784 ) 

1785 else: 

1786 value = self.config.get(name, default=simple.get("default")) 

1787 

1788 normalized[name] = value 

1789 

1790 # add to template context 

1791 context["simple_settings"] = normalized 

1792 

1793 return context 

1794 

1795 def configure_get_simple_settings(self): 

1796 """ 

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

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

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

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

1801 part of this method's return value.) 

1802 

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

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

1805 

1806 The setting definitions returned must each be a dict of 

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

1808 setting might be:: 

1809 

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

1811 

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

1813 is a more complete example:: 

1814 

1815 { 

1816 'name': 'wutta.production', 

1817 'type': bool, 

1818 'default': False, 

1819 'save_if_empty': False, 

1820 } 

1821 

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

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

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

1825 

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

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

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

1829 

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

1831 Note that their order does not matter since the template 

1832 must explicitly define field layout etc. 

1833 """ 

1834 return [] 

1835 

1836 def configure_gather_settings( 

1837 self, 

1838 data, 

1839 simple_settings=None, 

1840 ): 

1841 """ 

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

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

1844 

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

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

1847 the simple setting definitions. 

1848 

1849 Subclass may need to override this method if complex settings 

1850 are required. 

1851 

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

1853 

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

1855 already initialized. Otherwise it is retrieved via 

1856 :meth:`configure_get_simple_settings()`. 

1857 

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

1859 in spirit to the definition syntax used in 

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

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

1862 

1863 { 

1864 'name': 'wutta.app_title', 

1865 'value': 'Wutta Wutta', 

1866 } 

1867 

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

1869 

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

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

1872 contain all of them. 

1873 

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

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

1876 saved to DB) unless the setting definition has the 

1877 ``save_if_empty`` flag set. 

1878 """ 

1879 settings = [] 

1880 

1881 # simple settings 

1882 if simple_settings is None: 

1883 simple_settings = self.configure_get_simple_settings() 

1884 if simple_settings: 

1885 

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

1887 for simple in simple_settings: 

1888 name = simple["name"] 

1889 

1890 if name in data: 

1891 value = data[name] 

1892 elif simple.get("type") is bool: 

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

1894 value = False 

1895 else: 

1896 value = simple.get("default") 

1897 

1898 if simple.get("type") is bool: 

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

1900 elif simple.get("type") is int: 

1901 value = str(int(value or "0")) 

1902 elif value is None: 

1903 value = "" 

1904 else: 

1905 value = str(value) 

1906 

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

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

1909 if value or simple.get("save_if_empty"): 

1910 settings.append({"name": name, "value": value}) 

1911 

1912 return settings 

1913 

1914 def configure_remove_settings( 

1915 self, 

1916 simple_settings=None, 

1917 session=None, 

1918 ): 

1919 """ 

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

1921 :meth:`configure()`. 

1922 

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

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

1925 

1926 The default logic can handle this automatically for simple 

1927 settings; subclass must override for any complex settings. 

1928 

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

1930 already initialized. Otherwise it is retrieved via 

1931 :meth:`configure_get_simple_settings()`. 

1932 """ 

1933 names = [] 

1934 

1935 # simple settings 

1936 if simple_settings is None: 

1937 simple_settings = self.configure_get_simple_settings() 

1938 if simple_settings: 

1939 names.extend([simple["name"] for simple in simple_settings]) 

1940 

1941 if names: 

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

1943 # point to our primary app DB 

1944 session = session or self.Session() 

1945 for name in names: 

1946 self.app.delete_setting(session, name) 

1947 

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

1949 """ 

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

1951 :meth:`configure()`. 

1952 

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

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

1955 

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

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

1958 """ 

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

1960 # to our primary app DB 

1961 session = session or self.Session() 

1962 for setting in settings: 

1963 self.app.save_setting( 

1964 session, setting["name"], setting["value"], force_create=True 

1965 ) 

1966 

1967 ############################## 

1968 # grid rendering methods 

1969 ############################## 

1970 

1971 def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument 

1972 """ 

1973 Custom grid value renderer for "boolean" fields. 

1974 

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

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

1977 To use this feature for your grid:: 

1978 

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

1980 """ 

1981 if value is None: 

1982 return None 

1983 

1984 return "Yes" if value else "No" 

1985 

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

1987 """ 

1988 Custom grid value renderer for "currency" fields. 

1989 

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

1991 decimal as appropriate, and add the currency symbol. 

1992 

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

1994 default is 2 places. 

1995 

1996 To use this feature for your grid:: 

1997 

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

1999 

2000 # you can also override scale 

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

2002 """ 

2003 

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

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

2006 value = record[key] 

2007 

2008 if value is None: 

2009 return None 

2010 

2011 if value < 0: 

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

2013 return fmt.format(0 - value) 

2014 

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

2016 return fmt.format(value) 

2017 

2018 def grid_render_datetime( # pylint: disable=empty-docstring 

2019 self, record, key, value, fmt=None 

2020 ): 

2021 """ """ 

2022 warnings.warn( 

2023 "MasterView.grid_render_datetime() is deprecated; " 

2024 "please use app.render_datetime() directly instead", 

2025 DeprecationWarning, 

2026 stacklevel=2, 

2027 ) 

2028 

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

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

2031 value = record[key] 

2032 

2033 if value is None: 

2034 return None 

2035 

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

2037 

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

2039 """ 

2040 Custom grid value renderer for "enum" fields. 

2041 

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

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

2044 

2045 To use this feature for your grid:: 

2046 

2047 from enum import Enum 

2048 

2049 class MyEnum(Enum): 

2050 ONE = 1 

2051 TWO = 2 

2052 THREE = 3 

2053 

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

2055 """ 

2056 if enum: 

2057 original = record[key] 

2058 if original: 

2059 return original.name 

2060 

2061 return value 

2062 

2063 def grid_render_notes( # pylint: disable=unused-argument 

2064 self, record, key, value, maxlen=100 

2065 ): 

2066 """ 

2067 Custom grid value renderer for "notes" fields. 

2068 

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

2070 characters, it is returned as-is. 

2071 

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

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

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

2075 mouse hover. 

2076 

2077 To use this feature for your grid:: 

2078 

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

2080 

2081 # you can also override maxlen 

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

2083 """ 

2084 if value is None: 

2085 return None 

2086 

2087 if len(value) < maxlen: 

2088 return value 

2089 

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

2091 

2092 ############################## 

2093 # support methods 

2094 ############################## 

2095 

2096 def get_class_hierarchy(self, topfirst=True): 

2097 """ 

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

2099 class inherits. 

2100 

2101 This is a wrapper around 

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

2103 """ 

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

2105 

2106 def has_perm(self, name): 

2107 """ 

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

2109 

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

2111 ``name`` before passing it on to 

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

2113 

2114 For instance within the 

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

2116 result:: 

2117 

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

2119 

2120 self.has_perm('edit') 

2121 

2122 So this shortcut only applies to permissions defined for the 

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

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

2125 different prefix). 

2126 """ 

2127 permission_prefix = self.get_permission_prefix() 

2128 return self.request.has_perm(f"{permission_prefix}.{name}") 

2129 

2130 def has_any_perm(self, *names): 

2131 """ 

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

2133 permissions. 

2134 

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

2136 none do, returns ``False``. 

2137 """ 

2138 for name in names: 

2139 if self.has_perm(name): 

2140 return True 

2141 return False 

2142 

2143 def make_button( 

2144 self, 

2145 label, 

2146 variant=None, 

2147 primary=False, 

2148 url=None, 

2149 **kwargs, 

2150 ): 

2151 """ 

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

2153 

2154 :param label: Text label for the button. 

2155 

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

2157 for the button. Buefy and Oruga represent this differently 

2158 but this logic expects the Buefy format 

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

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

2161 terminology. 

2162 

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

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

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

2166 

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

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

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

2170 

2171 This is the preferred method where applicable, since it 

2172 avoids the Buefy vs. Oruga confusion, and the 

2173 implementation can change in the future. 

2174 

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

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

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

2178 

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

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

2181 attributes on the button tag. 

2182 

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

2184 reasons. 

2185 

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

2187 along the lines of: 

2188 

2189 .. code-block:: 

2190 

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

2192 icon-pack="fas" 

2193 icon-left="hand-pointer"> 

2194 Click Me 

2195 </b-button> 

2196 """ 

2197 btn_kw = kwargs 

2198 btn_kw.setdefault("c", label) 

2199 btn_kw.setdefault("icon_pack", "fas") 

2200 

2201 if "type" not in btn_kw: 

2202 if variant: 

2203 btn_kw["type"] = variant 

2204 elif primary: 

2205 btn_kw["type"] = "is-primary" 

2206 

2207 if url: 

2208 btn_kw["href"] = url 

2209 

2210 button = HTML.tag("b-button", **btn_kw) 

2211 

2212 if url: 

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

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

2215 # we patch that into place manually 

2216 button = str(button) 

2217 button = button.replace("<b-button ", '<b-button tag="a" ') 

2218 button = HTML.literal(button) 

2219 

2220 return button 

2221 

2222 def get_xref_buttons(self, obj): # pylint: disable=unused-argument 

2223 """ 

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

2225 when viewing the given object. 

2226 

2227 Default logic always returns empty list; subclass can override 

2228 as needed. 

2229 

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

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

2232 see/use. 

2233 

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

2235 

2236 def get_xref_buttons(self, product): 

2237 buttons = [] 

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

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

2240 id=product.external_id) 

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

2242 return buttons 

2243 """ 

2244 return [] 

2245 

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

2247 """ 

2248 Create and return a 

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

2250 given key. 

2251 

2252 This is normally done just before calling 

2253 :meth:`render_progress()`. 

2254 """ 

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

2256 

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

2258 """ 

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

2260 

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

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

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

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

2265 is redirected to the final destination. 

2266 

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

2268 

2269 :param progress: Progress indicator instance as returned by 

2270 :meth:`make_progress()`. 

2271 

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

2273 """ 

2274 template = template or "/progress.mako" 

2275 context = context or {} 

2276 context["progress"] = progress 

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

2278 

2279 def render_to_response(self, template, context): 

2280 """ 

2281 Locate and render an appropriate template, with the given 

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

2283 

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

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

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

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

2288 :meth:`get_fallback_templates()`. 

2289 

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

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

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

2293 

2294 * ``/widgets/edit.mako`` 

2295 * ``/master/edit.mako`` 

2296 

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

2298 It then calls 

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

2300 returns the result. 

2301 

2302 :param template: Base name for the template. 

2303 

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

2305 

2306 :returns: Response object containing the rendered template. 

2307 """ 

2308 defaults = { 

2309 "master": self, 

2310 "route_prefix": self.get_route_prefix(), 

2311 "index_title": self.get_index_title(), 

2312 "index_url": self.get_index_url(), 

2313 "model_title": self.get_model_title(), 

2314 "config_title": self.get_config_title(), 

2315 } 

2316 

2317 # merge defaults + caller-provided context 

2318 defaults.update(context) 

2319 context = defaults 

2320 

2321 # add crud flags if we have an instance 

2322 if "instance" in context: 

2323 instance = context["instance"] 

2324 if "instance_title" not in context: 

2325 context["instance_title"] = self.get_instance_title(instance) 

2326 if "instance_editable" not in context: 

2327 context["instance_editable"] = self.is_editable(instance) 

2328 if "instance_deletable" not in context: 

2329 context["instance_deletable"] = self.is_deletable(instance) 

2330 

2331 # supplement context further if needed 

2332 context = self.get_template_context(context) 

2333 

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

2335 page_templates = self.get_page_templates(template) 

2336 mako_path = page_templates[0] 

2337 try: 

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

2339 except IOError: 

2340 

2341 # failing that, try one or more fallback templates 

2342 for fallback in page_templates[1:]: 

2343 try: 

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

2345 except IOError: 

2346 pass 

2347 

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

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

2350 # let that error raise on up 

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

2352 

2353 def get_template_context(self, context): 

2354 """ 

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

2356 the current view template. 

2357 

2358 Default logic for this method returns the given context 

2359 unchanged. 

2360 

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

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

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

2364 context only for certain view templates. 

2365 

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

2367 auto-provided by the master view logic. 

2368 

2369 :returns: Final context dict for the template. 

2370 """ 

2371 return context 

2372 

2373 def get_page_templates(self, template): 

2374 """ 

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

2376 render the current page. This is called by 

2377 :meth:`render_to_response()`. 

2378 

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

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

2381 entries becoming more generic. 

2382 

2383 In practice this method defines the first entry but calls 

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

2385 

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

2387 ``'view'``. 

2388 

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

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

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

2392 

2393 [ 

2394 '/widgets/view.mako', 

2395 '/master/view.mako', 

2396 ] 

2397 

2398 """ 

2399 template_prefix = self.get_template_prefix() 

2400 page_templates = [f"{template_prefix}/{template}.mako"] 

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

2402 return page_templates 

2403 

2404 def get_fallback_templates(self, template): 

2405 """ 

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

2407 attempted for rendering the current page. See also 

2408 :meth:`get_page_templates()`. 

2409 

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

2411 ``'view'``. 

2412 

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

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

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

2416 

2417 ['/master/view.mako'] 

2418 """ 

2419 return [f"/master/{template}.mako"] 

2420 

2421 def get_index_title(self): 

2422 """ 

2423 Returns the main index title for the master view. 

2424 

2425 By default this returns the value from 

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

2427 needed. 

2428 """ 

2429 return self.get_model_title_plural() 

2430 

2431 def get_index_url(self, **kwargs): 

2432 """ 

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

2434 

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

2436 """ 

2437 if self.listable: 

2438 route_prefix = self.get_route_prefix() 

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

2440 return None 

2441 

2442 def set_labels(self, obj): 

2443 """ 

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

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

2446 

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

2448 :meth:`configure_form()`. 

2449 

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

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

2452 

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

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

2455 

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

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

2458 """ 

2459 labels = self.collect_labels() 

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

2461 obj.set_label(key, label) 

2462 

2463 def collect_labels(self): 

2464 """ 

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

2466 

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

2468 like so:: 

2469 

2470 from wuttaweb.views import MasterView 

2471 

2472 class WidgetView(MasterView): 

2473 

2474 labels = { 

2475 'id': "Widget ID", 

2476 'serial_no': "Serial Number", 

2477 } 

2478 

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

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

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

2482 wins. 

2483 

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

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

2486 

2487 :returns: Dict of all labels found. 

2488 """ 

2489 labels = {} 

2490 hierarchy = self.get_class_hierarchy() 

2491 for cls in hierarchy: 

2492 if hasattr(cls, "labels"): 

2493 labels.update(cls.labels) 

2494 return labels 

2495 

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

2497 """ 

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

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

2500 

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

2502 

2503 * :meth:`get_grid_key()` 

2504 * :meth:`get_grid_columns()` 

2505 * :meth:`get_grid_data()` 

2506 * :meth:`configure_grid()` 

2507 """ 

2508 if "key" not in kwargs: 

2509 kwargs["key"] = self.get_grid_key() 

2510 

2511 if "model_class" not in kwargs: 

2512 model_class = self.get_model_class() 

2513 if model_class: 

2514 kwargs["model_class"] = model_class 

2515 

2516 if "columns" not in kwargs: 

2517 kwargs["columns"] = self.get_grid_columns() 

2518 

2519 if "data" not in kwargs: 

2520 kwargs["data"] = self.get_grid_data( 

2521 columns=kwargs["columns"], session=session 

2522 ) 

2523 

2524 if "actions" not in kwargs: 

2525 actions = [] 

2526 

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

2528 

2529 if self.viewable and self.has_perm("view"): 

2530 actions.append( 

2531 self.make_grid_action( 

2532 "view", icon="eye", url=self.get_action_url_view 

2533 ) 

2534 ) 

2535 

2536 if self.editable and self.has_perm("edit"): 

2537 actions.append( 

2538 self.make_grid_action( 

2539 "edit", icon="edit", url=self.get_action_url_edit 

2540 ) 

2541 ) 

2542 

2543 if self.deletable and self.has_perm("delete"): 

2544 actions.append( 

2545 self.make_grid_action( 

2546 "delete", 

2547 icon="trash", 

2548 url=self.get_action_url_delete, 

2549 link_class="has-text-danger", 

2550 ) 

2551 ) 

2552 

2553 kwargs["actions"] = actions 

2554 

2555 if "tools" not in kwargs: 

2556 tools = [] 

2557 

2558 if self.deletable_bulk and self.has_perm("delete_bulk"): 

2559 tools.append(("delete-results", self.delete_bulk_make_button())) 

2560 

2561 kwargs["tools"] = tools 

2562 

2563 kwargs.setdefault("checkable", self.checkable) 

2564 if hasattr(self, "grid_row_class"): 

2565 kwargs.setdefault("row_class", self.grid_row_class) 

2566 kwargs.setdefault("filterable", self.filterable) 

2567 kwargs.setdefault("filter_defaults", self.filter_defaults) 

2568 kwargs.setdefault("sortable", self.sortable) 

2569 kwargs.setdefault("sort_on_backend", self.sort_on_backend) 

2570 kwargs.setdefault("sort_defaults", self.sort_defaults) 

2571 kwargs.setdefault("paginated", self.paginated) 

2572 kwargs.setdefault("paginate_on_backend", self.paginate_on_backend) 

2573 

2574 grid = self.make_grid(**kwargs) 

2575 self.configure_grid(grid) 

2576 grid.load_settings() 

2577 return grid 

2578 

2579 def get_grid_columns(self): 

2580 """ 

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

2582 :meth:`index()` view. 

2583 

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

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

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

2587 

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

2589 (try to) generate its own default list. 

2590 

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

2592 can override this method if needed. 

2593 

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

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

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

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

2598 remove or replace some of those within 

2599 :meth:`configure_grid()`. 

2600 """ 

2601 if hasattr(self, "grid_columns"): 

2602 return self.grid_columns 

2603 return None 

2604 

2605 def get_grid_data( # pylint: disable=unused-argument 

2606 self, columns=None, session=None 

2607 ): 

2608 """ 

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

2610 

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

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

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

2614 

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

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

2617 empty list. Subclass should override as needed. 

2618 """ 

2619 query = self.get_query(session=session) 

2620 if query: 

2621 return query 

2622 return [] 

2623 

2624 def get_query(self, session=None): 

2625 """ 

2626 Returns the main SQLAlchemy query object for the 

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

2628 :meth:`get_grid_data()`. 

2629 

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

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

2632 """ 

2633 model_class = self.get_model_class() 

2634 if model_class: 

2635 session = session or self.Session() 

2636 return session.query(model_class) 

2637 return None 

2638 

2639 def configure_grid(self, grid): 

2640 """ 

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

2642 

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

2644 

2645 There is minimal default logic here; subclass should override 

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

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

2648 based on request details etc. 

2649 """ 

2650 if "uuid" in grid.columns: 

2651 grid.columns.remove("uuid") 

2652 

2653 self.set_labels(grid) 

2654 

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

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

2657 # for key in self.get_model_key(): 

2658 # grid.set_link(key) 

2659 

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

2661 """ 

2662 This should return the appropriate model instance, based on 

2663 the ``matchdict`` of model keys. 

2664 

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

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

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

2668 (route/params). 

2669 

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

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

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

2673 

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

2675 obj = self.get_instance(matchdict=keys) 

2676 

2677 Although some models may have different, possibly composite 

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

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

2680 

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

2682 raise a 404 error, 

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

2684 

2685 Default implementation of this method should work okay for 

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

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

2688 may need to define. 

2689 

2690 .. warning:: 

2691 

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

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

2694 

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

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

2697 

2698 def get_instance(self, **kwargs): 

2699 

2700 # ..try to locate instance.. 

2701 obj = self.locate_instance_somehow() 

2702 

2703 if not obj: 

2704 

2705 # NB. THIS MAY NOT WORK AS EXPECTED 

2706 #return self.notfound() 

2707 

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

2709 raise self.notfound() 

2710 

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

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

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

2714 will kick in and control flow goes elsewhere. 

2715 """ 

2716 model_class = self.get_model_class() 

2717 if model_class: 

2718 session = session or self.Session() 

2719 matchdict = matchdict or self.request.matchdict 

2720 

2721 def filtr(query, model_key): 

2722 key = matchdict[model_key] 

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

2724 return query 

2725 

2726 query = session.query(model_class) 

2727 

2728 for key in self.get_model_key(): 

2729 query = filtr(query, key) 

2730 

2731 try: 

2732 return query.one() 

2733 except orm.exc.NoResultFound: 

2734 pass 

2735 

2736 raise self.notfound() 

2737 

2738 raise NotImplementedError( 

2739 "you must define get_instance() method " 

2740 f" for view class: {self.__class__}" 

2741 ) 

2742 

2743 def get_instance_title(self, instance): 

2744 """ 

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

2746 in the page title when viewing etc. 

2747 

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

2749 subclass may override if needed. 

2750 """ 

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

2752 

2753 def get_action_route_kwargs(self, obj): 

2754 """ 

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

2756 

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

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

2759 

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

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

2762 the object. 

2763 

2764 :param obj: Model instance object. 

2765 

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

2767 """ 

2768 try: 

2769 return {key: obj[key] for key in self.get_model_key()} 

2770 except TypeError: 

2771 return {key: getattr(obj, key) for key in self.get_model_key()} 

2772 

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

2774 """ 

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

2776 

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

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

2779 

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

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

2782 returns the result. 

2783 

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

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

2786 

2787 :param obj: Model instance object. 

2788 

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

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

2791 """ 

2792 kw = self.get_action_route_kwargs(obj) 

2793 kw.update(kwargs) 

2794 route_prefix = self.get_route_prefix() 

2795 return self.request.route_url(f"{route_prefix}.{action}", **kw) 

2796 

2797 def get_action_url_view(self, obj, i): # pylint: disable=unused-argument 

2798 """ 

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

2800 

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

2802 represents the object's key/ID. 

2803 

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

2805 """ 

2806 return self.get_action_url("view", obj) 

2807 

2808 def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument 

2809 """ 

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

2811 applicable. 

2812 

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

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

2815 

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

2817 this method will return ``None``. 

2818 

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

2820 """ 

2821 if self.is_editable(obj): 

2822 return self.get_action_url("edit", obj) 

2823 return None 

2824 

2825 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument 

2826 """ 

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

2828 applicable. 

2829 

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

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

2832 

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

2834 this method will return ``None``. 

2835 

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

2837 """ 

2838 if self.is_deletable(obj): 

2839 return self.get_action_url("delete", obj) 

2840 return None 

2841 

2842 def is_editable(self, obj): # pylint: disable=unused-argument 

2843 """ 

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

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

2846 

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

2848 if needed. 

2849 

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

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

2852 """ 

2853 return True 

2854 

2855 def is_deletable(self, obj): # pylint: disable=unused-argument 

2856 """ 

2857 Returns a boolean indicating whether "delete" should be 

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

2859 

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

2861 if needed. 

2862 

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

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

2865 """ 

2866 return True 

2867 

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

2869 """ 

2870 Make a form for the "model" represented by this subclass. 

2871 

2872 This method is normally called by all CRUD views: 

2873 

2874 * :meth:`create()` 

2875 * :meth:`view()` 

2876 * :meth:`edit()` 

2877 * :meth:`delete()` 

2878 

2879 The form need not have a ``model_instance``, as in the case of 

2880 :meth:`create()`. And it can be readonly as in the case of 

2881 :meth:`view()` and :meth:`delete()`. 

2882 

2883 If ``fields`` are not provided, :meth:`get_form_fields()` is 

2884 called. Usually a subclass will define :attr:`form_fields` 

2885 but it's only required if :attr:`model_class` is not set. 

2886 

2887 Then :meth:`configure_form()` is called, so subclass can go 

2888 crazy with that as needed. 

2889 

2890 :param model_instance: Model instance/record with which to 

2891 initialize the form data. Not needed for "create" forms. 

2892 

2893 :param fields: Optional fields list for the form. 

2894 

2895 :returns: :class:`~wuttaweb.forms.base.Form` instance 

2896 """ 

2897 if "model_class" not in kwargs: 

2898 model_class = self.get_model_class() 

2899 if model_class: 

2900 kwargs["model_class"] = model_class 

2901 

2902 kwargs["model_instance"] = model_instance 

2903 

2904 if not fields: 

2905 fields = self.get_form_fields() 

2906 if fields: 

2907 kwargs["fields"] = fields 

2908 

2909 form = self.make_form(**kwargs) 

2910 self.configure_form(form) 

2911 return form 

2912 

2913 def get_form_fields(self): 

2914 """ 

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

2916 

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

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

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

2920 

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

2922 (try to) generate its own default list. 

2923 

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

2925 can override this method if needed. 

2926 

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

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

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

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

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

2932 """ 

2933 if hasattr(self, "form_fields"): 

2934 return self.form_fields 

2935 return None 

2936 

2937 def configure_form(self, form): 

2938 """ 

2939 Configure the given model form, as needed. 

2940 

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

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

2943 

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

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

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

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

2948 values for a record. 

2949 

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

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

2952 can further modify it based on request details etc. 

2953 """ 

2954 form.remove("uuid") 

2955 

2956 self.set_labels(form) 

2957 

2958 # mark key fields as readonly to prevent edit. see also 

2959 # related comments in the objectify() method 

2960 if self.editing: 

2961 for key in self.get_model_key(): 

2962 form.set_readonly(key) 

2963 

2964 def objectify(self, form): 

2965 """ 

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

2967 validated form data. 

2968 

2969 In simple cases this may just return the 

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

2971 

2972 When dealing with SQLAlchemy models it would return a proper 

2973 mapped instance, creating it if necessary. 

2974 

2975 This is called by various other form-saving methods: 

2976 

2977 * :meth:`save_create_form()` 

2978 * :meth:`save_edit_form()` 

2979 * :meth:`create_row_save_form()` 

2980 

2981 See also :meth:`persist()`. 

2982 

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

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

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

2986 the data. 

2987 """ 

2988 

2989 # ColanderAlchemy schema has an objectify() method which will 

2990 # return a populated model instance 

2991 schema = form.get_schema() 

2992 if hasattr(schema, "objectify"): 

2993 return schema.objectify(form.validated, context=form.model_instance) 

2994 

2995 # at this point we likely have no model class, so have to 

2996 # assume we're operating on a simple dict record. we (mostly) 

2997 # want to return that as-is, unless subclass overrides. 

2998 data = dict(form.validated) 

2999 

3000 # nb. we have a unique scenario when *editing* for a simple 

3001 # dict record (no model class). we mark the key fields as 

3002 # readonly in configure_form(), so they aren't part of the 

3003 # data here, but we need to add them back for sake of 

3004 # e.g. generating the 'view' route kwargs for redirect. 

3005 if self.editing: 

3006 obj = self.get_instance() 

3007 for key in self.get_model_key(): 

3008 if key not in data: 

3009 data[key] = obj[key] 

3010 

3011 return data 

3012 

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

3014 """ 

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

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

3017 

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

3019 a model instance which already reflects the validated form 

3020 data. 

3021 

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

3023 override if needed. 

3024 

3025 :param obj: Model instance object as produced by 

3026 :meth:`objectify()`. 

3027 

3028 See also :meth:`save_create_form()` and 

3029 :meth:`save_edit_form()`, which call this method. 

3030 """ 

3031 model = self.app.model 

3032 model_class = self.get_model_class() 

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

3034 

3035 # add sqlalchemy model to session 

3036 session = session or self.Session() 

3037 session.add(obj) 

3038 

3039 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments 

3040 self, func, args, kwargs, onerror=None, session=None, progress=None 

3041 ): 

3042 """ 

3043 Generic method to invoke for thread operations. 

3044 

3045 :param func: Callable which performs the actual logic. This 

3046 will be wrapped with a try/except statement for error 

3047 handling. 

3048 

3049 :param args: Tuple of positional arguments to pass to the 

3050 ``func`` callable. 

3051 

3052 :param kwargs: Dict of keyword arguments to pass to the 

3053 ``func`` callable. 

3054 

3055 :param onerror: Optional callback to invoke if ``func`` raises 

3056 an error. It should not expect any arguments. 

3057 

3058 :param session: Optional :term:`db session` in effect. Note 

3059 that if supplied, it will be *committed* (or rolled back on 

3060 error) and *closed* by this method. If you need more 

3061 specialized handling, do not use this method (or don't 

3062 specify the ``session``). 

3063 

3064 :param progress: Optional progress factory. If supplied, this 

3065 is assumed to be a 

3066 :class:`~wuttaweb.progress.SessionProgress` instance, and 

3067 it will be updated per success or failure of ``func`` 

3068 invocation. 

3069 """ 

3070 try: 

3071 func(*args, **kwargs) 

3072 

3073 except Exception as error: # pylint: disable=broad-exception-caught 

3074 if session: 

3075 session.rollback() 

3076 if onerror: 

3077 onerror() 

3078 else: 

3079 log.warning("failed to invoke thread callable: %s", func, exc_info=True) 

3080 if progress: 

3081 progress.handle_error(error) 

3082 

3083 else: 

3084 if session: 

3085 session.commit() 

3086 if progress: 

3087 progress.handle_success() 

3088 

3089 finally: 

3090 if session: 

3091 session.close() 

3092 

3093 ############################## 

3094 # row methods 

3095 ############################## 

3096 

3097 def get_rows_title(self): 

3098 """ 

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

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

3101 true. 

3102 

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

3104 assigning :attr:`rows_title`. 

3105 """ 

3106 if hasattr(self, "rows_title"): 

3107 return self.rows_title 

3108 return self.get_row_model_title_plural() 

3109 

3110 def get_row_parent(self, row): 

3111 """ 

3112 This must return the parent object for the given child row. 

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

3114 

3115 Default logic is not implemented; subclass must override. 

3116 """ 

3117 raise NotImplementedError 

3118 

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

3120 """ 

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

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

3123 true. 

3124 

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

3126 being displayed. 

3127 

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

3129 rows data. 

3130 

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

3132 

3133 * :meth:`get_row_grid_key()` 

3134 * :meth:`get_row_grid_columns()` 

3135 * :meth:`get_row_grid_data()` 

3136 * :meth:`configure_row_grid()` 

3137 """ 

3138 if "key" not in kwargs: 

3139 kwargs["key"] = self.get_row_grid_key() 

3140 

3141 if "model_class" not in kwargs: 

3142 model_class = self.get_row_model_class() 

3143 if model_class: 

3144 kwargs["model_class"] = model_class 

3145 

3146 if "columns" not in kwargs: 

3147 kwargs["columns"] = self.get_row_grid_columns() 

3148 

3149 if "data" not in kwargs: 

3150 kwargs["data"] = self.get_row_grid_data(obj) 

3151 

3152 kwargs.setdefault("filterable", self.rows_filterable) 

3153 kwargs.setdefault("filter_defaults", self.rows_filter_defaults) 

3154 kwargs.setdefault("sortable", self.rows_sortable) 

3155 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend) 

3156 kwargs.setdefault("sort_defaults", self.rows_sort_defaults) 

3157 kwargs.setdefault("paginated", self.rows_paginated) 

3158 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend) 

3159 

3160 if "actions" not in kwargs: 

3161 actions = [] 

3162 

3163 if self.rows_viewable: 

3164 actions.append( 

3165 self.make_grid_action( 

3166 "view", icon="eye", url=self.get_row_action_url_view 

3167 ) 

3168 ) 

3169 

3170 if actions: 

3171 kwargs["actions"] = actions 

3172 

3173 grid = self.make_grid(**kwargs) 

3174 self.configure_row_grid(grid) 

3175 grid.load_settings() 

3176 return grid 

3177 

3178 def get_row_grid_key(self): 

3179 """ 

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

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

3182 :attr:`has_rows` is true. 

3183 

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

3185 resulting grid, this becomes 

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

3187 

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

3189 row grid key is always generated dynamically. This 

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

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

3192 """ 

3193 parts = [self.get_grid_key()] 

3194 for key in self.get_model_key(): 

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

3196 return ".".join(parts) 

3197 

3198 def get_row_grid_columns(self): 

3199 """ 

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

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

3202 :attr:`has_rows` is true. 

3203 

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

3205 resulting grid, this becomes 

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

3207 

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

3209 (try to) generate its own default list. 

3210 

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

3212 or can override this method if needed. 

3213 

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

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

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

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

3218 then optionally remove or replace some of those within 

3219 :meth:`configure_row_grid()`. 

3220 """ 

3221 if hasattr(self, "row_grid_columns"): 

3222 return self.row_grid_columns 

3223 return None 

3224 

3225 def get_row_grid_data(self, obj): 

3226 """ 

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

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

3229 

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

3231 resulting grid, this becomes 

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

3233 

3234 Default logic not implemented; subclass must define this. 

3235 """ 

3236 raise NotImplementedError 

3237 

3238 def configure_row_grid(self, grid): 

3239 """ 

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

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

3242 

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

3244 

3245 There is minimal default logic here; subclass should override 

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

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

3248 based on request details etc. 

3249 """ 

3250 grid.remove("uuid") 

3251 self.set_row_labels(grid) 

3252 

3253 def set_row_labels(self, obj): 

3254 """ 

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

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

3257 

3258 This is called automatically from 

3259 :meth:`configure_row_grid()` and 

3260 :meth:`configure_row_form()`. 

3261 

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

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

3264 type): 

3265 

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

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

3268 

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

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

3271 """ 

3272 labels = self.collect_row_labels() 

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

3274 obj.set_label(key, label) 

3275 

3276 def collect_row_labels(self): 

3277 """ 

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

3279 hierarchy. 

3280 

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

3282 

3283 :returns: Dict of all labels found. 

3284 """ 

3285 labels = {} 

3286 hierarchy = self.get_class_hierarchy() 

3287 for cls in hierarchy: 

3288 if hasattr(cls, "row_labels"): 

3289 labels.update(cls.row_labels) 

3290 return labels 

3291 

3292 def get_row_action_url_view(self, row, i): 

3293 """ 

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

3295 

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

3297 

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

3299 """ 

3300 raise NotImplementedError 

3301 

3302 def create_row(self): 

3303 """ 

3304 View to create a new "child row" record. 

3305 

3306 This usually corresponds to a URL like ``/widgets/XXX/new-row``. 

3307 

3308 By default, this view is included only if 

3309 :attr:`rows_creatable` is true. 

3310 

3311 The default "create row" view logic will show a form with 

3312 field widgets, allowing user to submit new values which are 

3313 then persisted to the DB (assuming typical SQLAlchemy model). 

3314 

3315 Subclass normally should not override this method, but rather 

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

3317 this one: 

3318 

3319 * :meth:`make_row_model_form()` 

3320 * :meth:`configure_row_form()` 

3321 * :meth:`create_row_save_form()` 

3322 * :meth:`redirect_after_create_row()` 

3323 """ 

3324 self.creating = True 

3325 parent = self.get_instance() 

3326 parent_url = self.get_action_url("view", parent) 

3327 

3328 form = self.make_row_model_form(cancel_url_fallback=parent_url) 

3329 if form.validate(): 

3330 result = self.create_row_save_form(form) 

3331 return self.redirect_after_create_row(result) 

3332 

3333 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

3334 parent_link = tags.link_to(self.get_instance_title(parent), parent_url) 

3335 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

3336 [index_link, parent_link] 

3337 ) 

3338 

3339 context = { 

3340 "form": form, 

3341 "index_title_rendered": index_title_rendered, 

3342 "row_model_title": self.get_row_model_title(), 

3343 } 

3344 return self.render_to_response("create_row", context) 

3345 

3346 def create_row_save_form(self, form): 

3347 """ 

3348 This method converts the validated form data to a row model 

3349 instance, and then saves the result to DB. It is called by 

3350 :meth:`create_row()`. 

3351 

3352 :returns: The resulting row model instance, as produced by 

3353 :meth:`objectify()`. 

3354 """ 

3355 row = self.objectify(form) 

3356 session = self.Session() 

3357 session.add(row) 

3358 session.flush() 

3359 return row 

3360 

3361 def redirect_after_create_row(self, row): 

3362 """ 

3363 Returns a redirect to the "view parent" page relative to the 

3364 given newly-created row. Subclass may override as needed. 

3365 

3366 This is called by :meth:`create_row()`. 

3367 """ 

3368 parent = self.get_row_parent(row) 

3369 return self.redirect(self.get_action_url("view", parent)) 

3370 

3371 def make_row_model_form(self, model_instance=None, **kwargs): 

3372 """ 

3373 Create and return a form for the row model. 

3374 

3375 This is called by :meth:`create_row()`. 

3376 

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

3378 

3379 * :meth:`get_row_model_class()` 

3380 * :meth:`get_row_form_fields()` 

3381 * :meth:`~wuttaweb.views.base.View.make_form()` 

3382 * :meth:`configure_row_form()` 

3383 

3384 :returns: :class:`~wuttaweb.forms.base.Form` instance 

3385 """ 

3386 if "model_class" not in kwargs: 

3387 model_class = self.get_row_model_class() 

3388 if model_class: 

3389 kwargs["model_class"] = model_class 

3390 

3391 kwargs["model_instance"] = model_instance 

3392 

3393 if not kwargs.get("fields"): 

3394 fields = self.get_row_form_fields() 

3395 if fields: 

3396 kwargs["fields"] = fields 

3397 

3398 form = self.make_form(**kwargs) 

3399 self.configure_row_form(form) 

3400 return form 

3401 

3402 def get_row_form_fields(self): 

3403 """ 

3404 Returns the initial list of field names for the row model 

3405 form. 

3406 

3407 This is called by :meth:`make_row_model_form()`; in the 

3408 resulting :class:`~wuttaweb.forms.base.Form` instance, this 

3409 becomes :attr:`~wuttaweb.forms.base.Form.fields`. 

3410 

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

3412 (try to) generate its own default list. 

3413 

3414 Subclass may define :attr:`row_form_fields` for simple cases, 

3415 or can override this method if needed. 

3416 

3417 Note that :meth:`configure_row_form()` may be used to further 

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

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

3420 fields by setting :attr:`row_form_fields` but then optionally 

3421 remove or replace some in :meth:`configure_row_form()`. 

3422 """ 

3423 if hasattr(self, "row_form_fields"): 

3424 return self.row_form_fields 

3425 return None 

3426 

3427 def configure_row_form(self, form): 

3428 """ 

3429 Configure the row model form. 

3430 

3431 This is called by :meth:`make_row_model_form()` - for multiple 

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

3433 

3434 The ``form`` param will already be "complete" and ready to use 

3435 as-is, but this method can further modify it based on request 

3436 details etc. 

3437 

3438 Subclass can override as needed, although be sure to invoke 

3439 this parent method via ``super()`` if so. 

3440 """ 

3441 form.remove("uuid") 

3442 self.set_row_labels(form) 

3443 

3444 ############################## 

3445 # class methods 

3446 ############################## 

3447 

3448 @classmethod 

3449 def get_model_class(cls): 

3450 """ 

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

3452 

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

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

3455 

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

3457 assigning :attr:`model_class`. 

3458 

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

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

3461 :attr:`model_name`. 

3462 """ 

3463 return cls.model_class 

3464 

3465 @classmethod 

3466 def get_model_name(cls): 

3467 """ 

3468 Returns the model name for the view. 

3469 

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

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

3472 *singular*, not plural.) 

3473 

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

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

3476 assigning :attr:`model_name`. 

3477 """ 

3478 if hasattr(cls, "model_name"): 

3479 return cls.model_name 

3480 

3481 return cls.get_model_class().__name__ 

3482 

3483 @classmethod 

3484 def get_model_name_normalized(cls): 

3485 """ 

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

3487 

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

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

3490 *singular*, not plural.) 

3491 

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

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

3494 assigning :attr:`model_name_normalized`. 

3495 """ 

3496 if hasattr(cls, "model_name_normalized"): 

3497 return cls.model_name_normalized 

3498 

3499 return cls.get_model_name().lower() 

3500 

3501 @classmethod 

3502 def get_model_title(cls): 

3503 """ 

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

3505 

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

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

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

3509 

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

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

3512 :attr:`model_title`. 

3513 """ 

3514 if hasattr(cls, "model_title"): 

3515 return cls.model_title 

3516 

3517 if model_class := cls.get_model_class(): 

3518 if hasattr(model_class, "__wutta_hint__"): 

3519 if model_title := model_class.__wutta_hint__.get("model_title"): 

3520 return model_title 

3521 

3522 return cls.get_model_name() 

3523 

3524 @classmethod 

3525 def get_model_title_plural(cls): 

3526 """ 

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

3528 

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

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

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

3532 

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

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

3535 assigning :attr:`model_title_plural`. 

3536 """ 

3537 if hasattr(cls, "model_title_plural"): 

3538 return cls.model_title_plural 

3539 

3540 if model_class := cls.get_model_class(): 

3541 if hasattr(model_class, "__wutta_hint__"): 

3542 if model_title_plural := model_class.__wutta_hint__.get( 

3543 "model_title_plural" 

3544 ): 

3545 return model_title_plural 

3546 

3547 model_title = cls.get_model_title() 

3548 return f"{model_title}s" 

3549 

3550 @classmethod 

3551 def get_model_key(cls): 

3552 """ 

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

3554 

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

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

3557 

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

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

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

3561 

3562 Any class mapped via SQLAlchemy should be supported 

3563 automatically, the keys are determined from class inspection. 

3564 

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

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

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

3568 

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

3570 """ 

3571 if hasattr(cls, "model_key"): 

3572 keys = cls.model_key 

3573 if isinstance(keys, str): 

3574 keys = [keys] 

3575 return tuple(keys) 

3576 

3577 model_class = cls.get_model_class() 

3578 if model_class: 

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

3580 # in case mapped class uses different prop keys 

3581 inspector = sa.inspect(model_class) 

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

3583 return tuple( 

3584 prop.key 

3585 for prop in inspector.column_attrs 

3586 if all(col.name in keys for col in prop.columns) 

3587 ) 

3588 

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

3590 

3591 @classmethod 

3592 def get_route_prefix(cls): 

3593 """ 

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

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

3596 

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

3598 might have these routes: 

3599 

3600 * ``'widgets'`` 

3601 * ``'widgets.create'`` 

3602 * ``'widgets.edit'`` 

3603 * ``'widgets.delete'`` 

3604 

3605 The default logic will call 

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

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

3608 assigning :attr:`route_prefix`. 

3609 """ 

3610 if hasattr(cls, "route_prefix"): 

3611 return cls.route_prefix 

3612 

3613 model_name = cls.get_model_name_normalized() 

3614 return f"{model_name}s" 

3615 

3616 @classmethod 

3617 def get_permission_prefix(cls): 

3618 """ 

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

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

3621 

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

3623 might have these permissions: 

3624 

3625 * ``'widgets.list'`` 

3626 * ``'widgets.create'`` 

3627 * ``'widgets.edit'`` 

3628 * ``'widgets.delete'`` 

3629 

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

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

3632 :attr:`permission_prefix`. 

3633 """ 

3634 if hasattr(cls, "permission_prefix"): 

3635 return cls.permission_prefix 

3636 

3637 return cls.get_route_prefix() 

3638 

3639 @classmethod 

3640 def get_url_prefix(cls): 

3641 """ 

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

3643 used for all URLs defined by the view class. 

3644 

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

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

3647 defined routes for these URLs: 

3648 

3649 * ``/widgets/`` 

3650 * ``/widgets/new`` 

3651 * ``/widgets/XXX/edit`` 

3652 * ``/widgets/XXX/delete`` 

3653 

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

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

3656 override by assigning :attr:`url_prefix`. 

3657 """ 

3658 if hasattr(cls, "url_prefix"): 

3659 return cls.url_prefix 

3660 

3661 route_prefix = cls.get_route_prefix() 

3662 return f"/{route_prefix}" 

3663 

3664 @classmethod 

3665 def get_instance_url_prefix(cls): 

3666 """ 

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

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

3669 winds up looking like: 

3670 

3671 * ``/widgets/{uuid}`` 

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

3673 

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

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

3676 symbols separating the key parts. 

3677 """ 

3678 prefix = cls.get_url_prefix() + "/" 

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

3680 if i: 

3681 prefix += "|" 

3682 prefix += f"{{{key}}}" 

3683 return prefix 

3684 

3685 @classmethod 

3686 def get_template_prefix(cls): 

3687 """ 

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

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

3690 given view. 

3691 

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

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

3694 assumed for those routes would be: 

3695 

3696 * ``/widgets/index.mako`` 

3697 * ``/widgets/create.mako`` 

3698 * ``/widgets/edit.mako`` 

3699 * ``/widgets/delete.mako`` 

3700 

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

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

3703 :attr:`template_prefix`. 

3704 """ 

3705 if hasattr(cls, "template_prefix"): 

3706 return cls.template_prefix 

3707 

3708 return cls.get_url_prefix() 

3709 

3710 @classmethod 

3711 def get_grid_key(cls): 

3712 """ 

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

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

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

3716 

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

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

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

3720 

3721 The default logic for this method will call 

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

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

3724 """ 

3725 if hasattr(cls, "grid_key"): 

3726 return cls.grid_key 

3727 

3728 return cls.get_route_prefix() 

3729 

3730 @classmethod 

3731 def get_config_title(cls): 

3732 """ 

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

3734 

3735 The config title is used for page title in the 

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

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

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

3739 Widgets** 

3740 

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

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

3743 :attr:`config_title`. 

3744 """ 

3745 if hasattr(cls, "config_title"): 

3746 return cls.config_title 

3747 

3748 return cls.get_model_title_plural() 

3749 

3750 @classmethod 

3751 def get_row_model_class(cls): 

3752 """ 

3753 Returns the "child row" model class for the view. Only 

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

3755 

3756 Default logic returns the :attr:`row_model_class` reference. 

3757 

3758 :returns: Mapped class, or ``None`` 

3759 """ 

3760 return cls.row_model_class 

3761 

3762 @classmethod 

3763 def get_row_model_name(cls): 

3764 """ 

3765 Returns the row model name for the view. 

3766 

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

3768 class name, e.g. ``'BatchRow'``. (Note this is *singular*, 

3769 not plural.) 

3770 

3771 The default logic will call :meth:`get_row_model_class()` and 

3772 return that class name as-is. Subclass may override by 

3773 assigning :attr:`row_model_name`. 

3774 """ 

3775 if hasattr(cls, "row_model_name"): 

3776 return cls.row_model_name 

3777 

3778 return cls.get_row_model_class().__name__ 

3779 

3780 @classmethod 

3781 def get_row_model_title(cls): 

3782 """ 

3783 Returns the "humanized" (singular) title for the row model. 

3784 

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

3786 proper grammar and capitalization, e.g. ``"Batch Row"``. 

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

3788 

3789 The default logic will call :meth:`get_row_model_name()` and 

3790 use the result as-is. Subclass may override by assigning 

3791 :attr:`row_model_title`. 

3792 

3793 See also :meth:`get_row_model_title_plural()`. 

3794 """ 

3795 if hasattr(cls, "row_model_title"): 

3796 return cls.row_model_title 

3797 

3798 return cls.get_row_model_name() 

3799 

3800 @classmethod 

3801 def get_row_model_title_plural(cls): 

3802 """ 

3803 Returns the "humanized" (plural) title for the row model. 

3804 

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

3806 proper grammar and capitalization, e.g. ``"Batch Rows"``. 

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

3808 

3809 The default logic will call :meth:`get_row_model_title()` and 

3810 simply add a ``'s'`` to the end. Subclass may override by 

3811 assigning :attr:`row_model_title_plural`. 

3812 """ 

3813 if hasattr(cls, "row_model_title_plural"): 

3814 return cls.row_model_title_plural 

3815 

3816 row_model_title = cls.get_row_model_title() 

3817 return f"{row_model_title}s" 

3818 

3819 ############################## 

3820 # configuration 

3821 ############################## 

3822 

3823 @classmethod 

3824 def defaults(cls, config): 

3825 """ 

3826 Provide default Pyramid configuration for a master view. 

3827 

3828 This is generally called from within the module's 

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

3830 

3831 from wuttaweb.views import MasterView 

3832 

3833 class WidgetView(MasterView): 

3834 model_name = 'Widget' 

3835 

3836 def includeme(config): 

3837 WidgetView.defaults(config) 

3838 

3839 :param config: Reference to the app's 

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

3841 """ 

3842 cls._defaults(config) 

3843 

3844 @classmethod 

3845 def _defaults(cls, config): # pylint: disable=too-many-statements 

3846 wutta_config = config.registry.settings.get("wutta_config") 

3847 app = wutta_config.get_app() 

3848 

3849 route_prefix = cls.get_route_prefix() 

3850 permission_prefix = cls.get_permission_prefix() 

3851 url_prefix = cls.get_url_prefix() 

3852 model_title = cls.get_model_title() 

3853 model_title_plural = cls.get_model_title_plural() 

3854 

3855 # add to master view registry 

3856 config.add_wutta_master_view(cls) 

3857 

3858 # permission group 

3859 config.add_wutta_permission_group( 

3860 permission_prefix, model_title_plural, overwrite=False 

3861 ) 

3862 

3863 # index 

3864 if cls.listable: 

3865 config.add_route(route_prefix, f"{url_prefix}/") 

3866 config.add_view( 

3867 cls, 

3868 attr="index", 

3869 route_name=route_prefix, 

3870 permission=f"{permission_prefix}.list", 

3871 ) 

3872 config.add_wutta_permission( 

3873 permission_prefix, 

3874 f"{permission_prefix}.list", 

3875 f"Browse / search {model_title_plural}", 

3876 ) 

3877 

3878 # create 

3879 if cls.creatable: 

3880 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new") 

3881 config.add_view( 

3882 cls, 

3883 attr="create", 

3884 route_name=f"{route_prefix}.create", 

3885 permission=f"{permission_prefix}.create", 

3886 ) 

3887 config.add_wutta_permission( 

3888 permission_prefix, 

3889 f"{permission_prefix}.create", 

3890 f"Create new {model_title}", 

3891 ) 

3892 

3893 # edit 

3894 if cls.editable: 

3895 instance_url_prefix = cls.get_instance_url_prefix() 

3896 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit") 

3897 config.add_view( 

3898 cls, 

3899 attr="edit", 

3900 route_name=f"{route_prefix}.edit", 

3901 permission=f"{permission_prefix}.edit", 

3902 ) 

3903 config.add_wutta_permission( 

3904 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}" 

3905 ) 

3906 

3907 # delete 

3908 if cls.deletable: 

3909 instance_url_prefix = cls.get_instance_url_prefix() 

3910 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete") 

3911 config.add_view( 

3912 cls, 

3913 attr="delete", 

3914 route_name=f"{route_prefix}.delete", 

3915 permission=f"{permission_prefix}.delete", 

3916 ) 

3917 config.add_wutta_permission( 

3918 permission_prefix, 

3919 f"{permission_prefix}.delete", 

3920 f"Delete {model_title}", 

3921 ) 

3922 

3923 # bulk delete 

3924 if cls.deletable_bulk: 

3925 config.add_route( 

3926 f"{route_prefix}.delete_bulk", 

3927 f"{url_prefix}/delete-bulk", 

3928 request_method="POST", 

3929 ) 

3930 config.add_view( 

3931 cls, 

3932 attr="delete_bulk", 

3933 route_name=f"{route_prefix}.delete_bulk", 

3934 permission=f"{permission_prefix}.delete_bulk", 

3935 ) 

3936 config.add_wutta_permission( 

3937 permission_prefix, 

3938 f"{permission_prefix}.delete_bulk", 

3939 f"Delete {model_title_plural} in bulk", 

3940 ) 

3941 

3942 # autocomplete 

3943 if cls.has_autocomplete: 

3944 config.add_route( 

3945 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete" 

3946 ) 

3947 config.add_view( 

3948 cls, 

3949 attr="autocomplete", 

3950 route_name=f"{route_prefix}.autocomplete", 

3951 renderer="json", 

3952 permission=f"{route_prefix}.list", 

3953 ) 

3954 

3955 # download 

3956 if cls.downloadable: 

3957 instance_url_prefix = cls.get_instance_url_prefix() 

3958 config.add_route( 

3959 f"{route_prefix}.download", f"{instance_url_prefix}/download" 

3960 ) 

3961 config.add_view( 

3962 cls, 

3963 attr="download", 

3964 route_name=f"{route_prefix}.download", 

3965 permission=f"{permission_prefix}.download", 

3966 ) 

3967 config.add_wutta_permission( 

3968 permission_prefix, 

3969 f"{permission_prefix}.download", 

3970 f"Download file(s) for {model_title}", 

3971 ) 

3972 

3973 # execute 

3974 if cls.executable: 

3975 instance_url_prefix = cls.get_instance_url_prefix() 

3976 config.add_route( 

3977 f"{route_prefix}.execute", 

3978 f"{instance_url_prefix}/execute", 

3979 request_method="POST", 

3980 ) 

3981 config.add_view( 

3982 cls, 

3983 attr="execute", 

3984 route_name=f"{route_prefix}.execute", 

3985 permission=f"{permission_prefix}.execute", 

3986 ) 

3987 config.add_wutta_permission( 

3988 permission_prefix, 

3989 f"{permission_prefix}.execute", 

3990 f"Execute {model_title}", 

3991 ) 

3992 

3993 # configure 

3994 if cls.configurable: 

3995 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure") 

3996 config.add_view( 

3997 cls, 

3998 attr="configure", 

3999 route_name=f"{route_prefix}.configure", 

4000 permission=f"{permission_prefix}.configure", 

4001 ) 

4002 config.add_wutta_permission( 

4003 permission_prefix, 

4004 f"{permission_prefix}.configure", 

4005 f"Configure {model_title_plural}", 

4006 ) 

4007 

4008 # view 

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

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

4011 if cls.viewable: 

4012 instance_url_prefix = cls.get_instance_url_prefix() 

4013 config.add_route(f"{route_prefix}.view", instance_url_prefix) 

4014 config.add_view( 

4015 cls, 

4016 attr="view", 

4017 route_name=f"{route_prefix}.view", 

4018 permission=f"{permission_prefix}.view", 

4019 ) 

4020 config.add_wutta_permission( 

4021 permission_prefix, f"{permission_prefix}.view", f"View {model_title}" 

4022 ) 

4023 

4024 # version history 

4025 if cls.is_versioned() and app.continuum_is_enabled(): 

4026 instance_url_prefix = cls.get_instance_url_prefix() 

4027 config.add_wutta_permission( 

4028 permission_prefix, 

4029 f"{permission_prefix}.versions", 

4030 f"View version history for {model_title}", 

4031 ) 

4032 config.add_route( 

4033 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/" 

4034 ) 

4035 config.add_view( 

4036 cls, 

4037 attr="view_versions", 

4038 route_name=f"{route_prefix}.versions", 

4039 permission=f"{permission_prefix}.versions", 

4040 ) 

4041 config.add_route( 

4042 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}" 

4043 ) 

4044 config.add_view( 

4045 cls, 

4046 attr="view_version", 

4047 route_name=f"{route_prefix}.version", 

4048 permission=f"{permission_prefix}.versions", 

4049 ) 

4050 

4051 ############################## 

4052 # row-specific routes 

4053 ############################## 

4054 

4055 # create row 

4056 if cls.has_rows and cls.rows_creatable: 

4057 config.add_wutta_permission( 

4058 permission_prefix, 

4059 f"{permission_prefix}.create_row", 

4060 f'Create new "rows" for {model_title}', 

4061 ) 

4062 config.add_route( 

4063 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row" 

4064 ) 

4065 config.add_view( 

4066 cls, 

4067 attr="create_row", 

4068 route_name=f"{route_prefix}.create_row", 

4069 permission=f"{permission_prefix}.create_row", 

4070 )