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

1117 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-06 22:20 -0500

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 

32from uuid import UUID 

33 

34import sqlalchemy as sa 

35from sqlalchemy import orm 

36 

37from pyramid.renderers import render_to_response 

38from webhelpers2.html import HTML, tags 

39 

40from wuttjamaican.util import get_class_hierarchy 

41from wuttaweb.views.base import View 

42from wuttaweb.util import get_form_data, render_csrf_token 

43from wuttaweb.db import Session 

44from wuttaweb.progress import SessionProgress 

45from wuttaweb.diffs import MergeDiff, VersionDiff 

46 

47 

48log = logging.getLogger(__name__) 

49 

50 

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

52 """ 

53 Base class for "master" views. 

54 

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

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

57 data record. 

58 

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

60 define :attr:`model_class`:: 

61 

62 from wuttaweb.views import MasterView 

63 from wuttjamaican.db.model import Person 

64 

65 class MyPersonView(MasterView): 

66 model_class = Person 

67 

68 def includeme(config): 

69 MyPersonView.defaults(config) 

70 

71 .. note:: 

72 

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

74 explicitly defined in a subclass. There are corresponding 

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

76 these attributes directly. 

77 

78 .. attribute:: model_class 

79 

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

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

82 mapped class, 

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

84 

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

86 :meth:`get_model_class()`. 

87 

88 .. attribute:: model_name 

89 

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

91 e.g. ``'WuttaWidget'``. 

92 

93 Code should not access this directly but instead call 

94 :meth:`get_model_name()`. 

95 

96 .. attribute:: model_name_normalized 

97 

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

99 e.g. ``'wutta_widget'``. 

100 

101 Code should not access this directly but instead call 

102 :meth:`get_model_name_normalized()`. 

103 

104 .. attribute:: model_title 

105 

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

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

108 

109 Code should not access this directly but instead call 

110 :meth:`get_model_title()`. 

111 

112 .. attribute:: model_title_plural 

113 

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

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

116 

117 Code should not access this directly but instead call 

118 :meth:`get_model_title_plural()`. 

119 

120 .. attribute:: model_key 

121 

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

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

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

125 

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

127 model key can be determined automatically. 

128 

129 Code should not access this directly but instead call 

130 :meth:`get_model_key()`. 

131 

132 .. attribute:: grid_key 

133 

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

135 

136 Code should not access this directly but instead call 

137 :meth:`get_grid_key()`. 

138 

139 .. attribute:: config_title 

140 

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

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

143 

144 Code should not access this directly but instead call 

145 :meth:`get_config_title()`. 

146 

147 .. attribute:: route_prefix 

148 

149 Optional override for the view's route prefix, 

150 e.g. ``'wutta_widgets'``. 

151 

152 Code should not access this directly but instead call 

153 :meth:`get_route_prefix()`. 

154 

155 .. attribute:: permission_prefix 

156 

157 Optional override for the view's permission prefix, 

158 e.g. ``'wutta_widgets'``. 

159 

160 Code should not access this directly but instead call 

161 :meth:`get_permission_prefix()`. 

162 

163 .. attribute:: url_prefix 

164 

165 Optional override for the view's URL prefix, 

166 e.g. ``'/widgets'``. 

167 

168 Code should not access this directly but instead call 

169 :meth:`get_url_prefix()`. 

170 

171 .. attribute:: template_prefix 

172 

173 Optional override for the view's template prefix, 

174 e.g. ``'/widgets'``. 

175 

176 Code should not access this directly but instead call 

177 :meth:`get_template_prefix()`. 

178 

179 .. attribute:: listable 

180 

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

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

183 ``True``. 

184 

185 .. attribute:: has_grid 

186 

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

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

189 

190 .. attribute:: grid_columns 

191 

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

193 

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

195 

196 .. attribute:: has_grid_totals 

197 

198 Boolean indicating whether the main grid supports a "Show 

199 Totals" feature; this is false by default. 

200 

201 See also :meth:`fetch_grid_totals()`. 

202 

203 .. attribute:: checkable 

204 

205 Boolean indicating whether the grid should expose per-row 

206 checkboxes. This is passed along to set 

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

208 

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

210 

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

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

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

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

215 

216 For more info see 

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

218 

219 .. attribute:: filterable 

220 

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

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

223 

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

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

226 

227 .. attribute:: filter_defaults 

228 

229 Optional dict of default filter state. 

230 

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

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

233 

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

235 

236 .. attribute:: sortable 

237 

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

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

240 

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

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

243 

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

245 

246 .. attribute:: sort_on_backend 

247 

248 Boolean indicating whether the grid data for the 

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

250 is ``True``. 

251 

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

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

254 

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

256 

257 .. attribute:: sort_defaults 

258 

259 Optional list of default sorting info. Applicable for both 

260 frontend and backend sorting. 

261 

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

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

264 

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

266 

267 .. attribute:: paginated 

268 

269 Boolean indicating whether the grid data for the 

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

271 

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

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

274 

275 .. attribute:: paginate_on_backend 

276 

277 Boolean indicating whether the grid data for the 

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

279 Default is ``True``. 

280 

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

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

283 

284 .. attribute:: creatable 

285 

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

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

288 ``True``. 

289 

290 .. attribute:: viewable 

291 

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

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

294 ``True``. 

295 

296 .. attribute:: editable 

297 

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

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

300 ``True``. 

301 

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

303 

304 .. attribute:: deletable 

305 

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

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

308 ``True``. 

309 

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

311 

312 .. attribute:: deletable_bulk 

313 

314 Boolean indicating whether the view model supports "bulk 

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

316 Default value is ``False``. 

317 

318 See also :attr:`deletable_bulk_quick`. 

319 

320 .. attribute:: deletable_bulk_quick 

321 

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

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

324 should happen *synchronously* with no progress indicator. 

325 

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

327 shown while the bulk deletion is performed. 

328 

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

330 

331 .. attribute:: form_fields 

332 

333 List of fields for the model form. 

334 

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

336 

337 .. attribute:: has_autocomplete 

338 

339 Boolean indicating whether the view model supports 

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

341 view. Default is ``False``. 

342 

343 .. attribute:: downloadable 

344 

345 Boolean indicating whether the view model supports 

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

347 Default is ``False``. 

348 

349 .. attribute:: executable 

350 

351 Boolean indicating whether the view model supports "executing" 

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

353 ``False``. 

354 

355 .. attribute:: configurable 

356 

357 Boolean indicating whether the master view supports 

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

359 Default value is ``False``. 

360 

361 .. attribute:: version_grid_columns 

362 

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

364 

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

366 

367 .. attribute:: mergeable 

368 

369 Boolean indicating whether the view model supports "merging two 

370 records" - i.e. it should have a :meth:`merge()` view. Default 

371 value is ``False``. 

372 

373 .. attribute:: merge_additive_fields 

374 

375 Optional list of fields for which values are "additive" in 

376 nature when merging two records. Only relevant if 

377 :attr:`mergeable` is true. 

378 

379 See also :meth:`merge_get_additive_fields()`. 

380 

381 .. attribute:: merge_coalesce_fields 

382 

383 Optional list of fields for which values should be "coalesced" 

384 when merging two records. Only relevant if :attr:`mergeable` 

385 is true. 

386 

387 See also :meth:`merge_get_coalesce_fields()`. 

388 

389 .. attribute:: merge_simple_fields 

390 

391 Optional list of "simple" fields when merging two records. 

392 Only relevant if :attr:`mergeable` is true. 

393 

394 See also :meth:`merge_get_simple_fields()`. 

395 

396 **ROW FEATURES** 

397 

398 .. attribute:: has_rows 

399 

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

401 displayed when viewing model records. For instance when 

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

403 as well as its row data. 

404 

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

406 on then many other things kick in. 

407 

408 See also :attr:`row_model_class`. 

409 

410 .. attribute:: row_model_class 

411 

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

413 

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

415 

416 View logic should not access this directly but instead call 

417 :meth:`get_row_model_class()`. 

418 

419 .. attribute:: row_model_name 

420 

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

422 e.g. ``'WuttaWidget'``. 

423 

424 Code should not access this directly but instead call 

425 :meth:`get_row_model_name()`. 

426 

427 .. attribute:: row_model_title 

428 

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

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

431 

432 Code should not access this directly but instead call 

433 :meth:`get_row_model_title()`. 

434 

435 .. attribute:: row_model_title_plural 

436 

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

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

439 

440 Code should not access this directly but instead call 

441 :meth:`get_row_model_title_plural()`. 

442 

443 .. attribute:: rows_title 

444 

445 Display title for the rows grid. 

446 

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

448 :meth:`get_rows_title()`. 

449 

450 .. attribute:: row_grid_columns 

451 

452 List of columns for the row grid. 

453 

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

455 

456 .. attribute:: rows_viewable 

457 

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

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

460 is ``False``. 

461 

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

463 :meth:`get_row_action_url_view()`. 

464 

465 .. note:: 

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

467 to be configured as well. 

468 

469 .. attribute:: row_form_fields 

470 

471 List of fields for the row model form. 

472 

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

474 

475 .. attribute:: rows_creatable 

476 

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

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

479 Default value is ``False``. 

480 """ 

481 

482 ############################## 

483 # attributes 

484 ############################## 

485 

486 model_class = None 

487 

488 # features 

489 listable = True 

490 has_grid = True 

491 has_grid_totals = False 

492 checkable = False 

493 filterable = True 

494 filter_defaults = None 

495 sortable = True 

496 sort_on_backend = True 

497 sort_defaults = None 

498 paginated = True 

499 paginate_on_backend = True 

500 creatable = True 

501 viewable = True 

502 editable = True 

503 deletable = True 

504 deletable_bulk = False 

505 deletable_bulk_quick = False 

506 has_autocomplete = False 

507 downloadable = False 

508 executable = False 

509 execute_progress_template = None 

510 configurable = False 

511 

512 # merging 

513 mergeable = False 

514 merge_additive_fields = None 

515 merge_coalesce_fields = None 

516 merge_simple_fields = None 

517 

518 # row features 

519 has_rows = False 

520 row_model_class = None 

521 rows_filterable = True 

522 rows_filter_defaults = None 

523 rows_sortable = True 

524 rows_sort_on_backend = True 

525 rows_sort_defaults = None 

526 rows_paginated = True 

527 rows_paginate_on_backend = True 

528 rows_viewable = False 

529 rows_creatable = False 

530 

531 # current action 

532 listing = False 

533 creating = False 

534 viewing = False 

535 editing = False 

536 deleting = False 

537 executing = False 

538 configuring = False 

539 

540 # default DB session 

541 Session = Session 

542 

543 ############################## 

544 # index methods 

545 ############################## 

546 

547 def index(self): 

548 """ 

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

550 

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

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

553 e.g. ``/widgets/``. 

554 

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

556 true. 

557 

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

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

560 

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

562 

563 * :meth:`make_model_grid()` 

564 """ 

565 self.listing = True 

566 

567 context = { 

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

569 } 

570 

571 if self.has_grid: 

572 grid = self.make_model_grid() 

573 

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

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

576 

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

578 context = grid.get_vue_context() 

579 if grid.paginated and grid.paginate_on_backend: 

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

581 return self.json_response(context) 

582 

583 # full, not partial 

584 

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

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

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

588 

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

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

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

592 

593 context["grid"] = grid 

594 

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

596 

597 def fetch_grid_totals(self): 

598 """ 

599 Should return the "totals info" for the main grid, if 

600 applicable. Only relevant when :attr:`has_grid_totals` is 

601 true. 

602 

603 This method is called "on demand" from the client side; totals 

604 are not calculated / shown by default when a grid is first 

605 displayed on the page. 

606 

607 Subclass should override this method to calculate and return 

608 the customized info. Default logic within the template is 

609 expecting a ``totals_html`` key within the dict; this will be 

610 rendered as-is on the page. For instance:: 

611 

612 def fetch_grid_totals(self): 

613 

614 from webhelpers2.html import HTML 

615 

616 # get current data set from grid 

617 # nb. this will be filtered and sorted but *not* 

618 # paginated; we want to include all results. 

619 grid = self.make_model_grid(paginated=False) 

620 rows = grid.get_visible_data() 

621 

622 # calculate total 

623 foo_total = sum([row.foo_amount for row in rows]) 

624 

625 # render as <span> tag 

626 html = HTML.tag("span", c=f"Foo Total: {foo_total:0.2f}") 

627 return {"totals_html": html} 

628 

629 :returns: Dict of totals info. 

630 """ 

631 return {"totals_html": "TODO: totals go here"} 

632 

633 ############################## 

634 # create methods 

635 ############################## 

636 

637 def create(self): 

638 """ 

639 View to "create" a new model record. 

640 

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

642 

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

644 is true. 

645 

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

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

648 :meth:`save_create_form()` and then 

649 :meth:`redirect_after_create()`. 

650 """ 

651 self.creating = True 

652 form = self.make_create_form() 

653 

654 if form.validate(): 

655 session = self.Session() 

656 try: 

657 result = self.save_create_form(form) 

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

659 session.flush() 

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

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

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

663 else: 

664 return self.redirect_after_create(result) 

665 

666 context = {"form": form} 

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

668 

669 def make_create_form(self): 

670 """ 

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

672 :meth:`create()`. 

673 

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

675 

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

677 """ 

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

679 

680 def save_create_form(self, form): 

681 """ 

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

683 

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

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

686 non-standard use cases. 

687 

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

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

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

691 :meth:`redirect_after_create()`. 

692 

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

694 """ 

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

696 warnings.warn( 

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

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

699 DeprecationWarning, 

700 ) 

701 return self.create_save_form(form) 

702 

703 obj = self.objectify(form) 

704 self.persist(obj) 

705 return obj 

706 

707 def redirect_after_create(self, result): 

708 """ 

709 Must return a redirect, following successful save of the 

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

711 

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

713 record. 

714 

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

716 """ 

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

718 

719 ############################## 

720 # view methods 

721 ############################## 

722 

723 def view(self): 

724 """ 

725 View to "view" a model record. 

726 

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

728 

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

730 true. 

731 

732 The default logic here is as follows: 

733 

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

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

736 

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

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

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

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

741 sorting and pagination etc.) 

742 

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

744 is rendered. 

745 """ 

746 self.viewing = True 

747 obj = self.get_instance() 

748 context = {"instance": obj} 

749 

750 if self.has_rows: 

751 

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

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

754 grid = self.make_row_model_grid(obj) 

755 

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

757 # redirect so the query string gets cleared out 

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

759 

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

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

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

763 

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

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

766 context = grid.get_vue_context() 

767 if grid.paginated and grid.paginate_on_backend: 

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

769 return self.json_response(context) 

770 

771 context["rows_grid"] = grid 

772 

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

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

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

776 

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

778 """ 

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

780 :meth:`view()`. 

781 

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

783 

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

785 """ 

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

787 

788 ############################## 

789 # edit methods 

790 ############################## 

791 

792 def edit(self): 

793 """ 

794 View to "edit" a model record. 

795 

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

797 

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

799 true. 

800 

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

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

803 :meth:`save_edit_form()` and then 

804 :meth:`redirect_after_edit()`. 

805 """ 

806 self.editing = True 

807 instance = self.get_instance() 

808 form = self.make_edit_form(instance) 

809 

810 if form.validate(): 

811 try: 

812 result = self.save_edit_form(form) 

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

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

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

816 else: 

817 return self.redirect_after_edit(result) 

818 

819 context = { 

820 "instance": instance, 

821 "form": form, 

822 } 

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

824 

825 def make_edit_form(self, obj): 

826 """ 

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

828 :meth:`edit()`. 

829 

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

831 

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

833 """ 

834 return self.make_model_form( 

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

836 ) 

837 

838 def save_edit_form(self, form): 

839 """ 

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

841 

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

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

844 non-standard use cases. 

845 

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

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

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

849 :meth:`redirect_after_edit()`. 

850 

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

852 """ 

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

854 warnings.warn( 

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

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

857 DeprecationWarning, 

858 ) 

859 return self.edit_save_form(form) 

860 

861 obj = self.objectify(form) 

862 self.persist(obj) 

863 return obj 

864 

865 def redirect_after_edit(self, result): 

866 """ 

867 Must return a redirect, following successful save of the 

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

869 

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

871 

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

873 """ 

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

875 

876 ############################## 

877 # delete methods 

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

879 

880 def delete(self): 

881 """ 

882 View to "delete" a model record. 

883 

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

885 

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

887 is true. 

888 

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

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

891 :meth:`save_delete_form()` and then 

892 :meth:`redirect_after_delete()`. 

893 """ 

894 self.deleting = True 

895 instance = self.get_instance() 

896 

897 if not self.is_deletable(instance): 

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

899 

900 form = self.make_delete_form(instance) 

901 

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

903 if form.validate() is not False: 

904 

905 try: 

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

907 form 

908 ) 

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

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

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

912 else: 

913 return self.redirect_after_delete(result) 

914 

915 context = { 

916 "instance": instance, 

917 "form": form, 

918 } 

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

920 

921 def make_delete_form(self, obj): 

922 """ 

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

924 :meth:`delete()`. 

925 

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

927 twist: 

928 

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

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

931 explicitly marked readonly. 

932 

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

934 """ 

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

936 form = self.make_model_form( 

937 obj, 

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

939 button_label_submit="DELETE Forever", 

940 button_icon_submit="trash", 

941 button_type_submit="is-danger", 

942 ) 

943 

944 # ..but *all* fields are readonly 

945 form.readonly_fields = set(form.fields) 

946 return form 

947 

948 def save_delete_form(self, form): 

949 """ 

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

951 

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

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

954 it could also/instead override this method. 

955 

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

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

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

959 

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

961 """ 

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

963 warnings.warn( 

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

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

966 DeprecationWarning, 

967 ) 

968 self.delete_save_form(form) 

969 return 

970 

971 obj = form.model_instance 

972 self.delete_instance(obj) 

973 

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

975 """ 

976 Must return a redirect, following successful save of the 

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

978 

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

980 

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

982 """ 

983 return self.redirect(self.get_index_url()) 

984 

985 def delete_instance(self, obj): 

986 """ 

987 Delete the given model instance. 

988 

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

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

991 needed. 

992 

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

994 """ 

995 session = self.app.get_session(obj) 

996 session.delete(obj) 

997 

998 def delete_bulk(self): 

999 """ 

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

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

1002 

1003 This usually corresponds to a URL like 

1004 ``/widgets/delete-bulk``. 

1005 

1006 By default, this view is included only if 

1007 :attr:`deletable_bulk` is true. 

1008 

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

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

1011 

1012 Subclass normally should not override this method, but rather 

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

1014 this one: 

1015 

1016 * :meth:`delete_bulk_action()` 

1017 """ 

1018 

1019 # get current data set from grid 

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

1021 grid = self.make_model_grid(paginated=False) 

1022 data = grid.get_visible_data() 

1023 

1024 if self.deletable_bulk_quick: 

1025 

1026 # delete it all and go back to listing 

1027 self.delete_bulk_action(data) 

1028 return self.redirect(self.get_index_url()) 

1029 

1030 # start thread for delete; show progress page 

1031 route_prefix = self.get_route_prefix() 

1032 key = f"{route_prefix}.delete_bulk" 

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

1034 thread = threading.Thread( 

1035 target=self.delete_bulk_thread, 

1036 args=(data,), 

1037 kwargs={"progress": progress}, 

1038 ) 

1039 thread.start() 

1040 return self.render_progress(progress) 

1041 

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

1043 self, query, progress=None 

1044 ): 

1045 """ """ 

1046 session = self.app.make_session() 

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

1048 

1049 def onerror(): 

1050 log.warning( 

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

1052 len(records), 

1053 self.get_model_title_plural(), 

1054 exc_info=True, 

1055 ) 

1056 

1057 self.do_thread_body( 

1058 self.delete_bulk_action, 

1059 (records,), 

1060 {"progress": progress}, 

1061 onerror, 

1062 session=session, 

1063 progress=progress, 

1064 ) 

1065 

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

1067 """ 

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

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

1070 

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

1072 record, and if that returns true then it calls 

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

1074 updated if one is provided. 

1075 

1076 Subclass should override if needed. 

1077 """ 

1078 model_title_plural = self.get_model_title_plural() 

1079 

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

1081 if self.is_deletable(obj): 

1082 self.delete_instance(obj) 

1083 

1084 self.app.progress_loop( 

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

1086 ) 

1087 

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

1089 """ """ 

1090 route_prefix = self.get_route_prefix() 

1091 

1092 label = HTML.literal( 

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

1094 ) 

1095 button = self.make_button( 

1096 label, 

1097 variant="is-danger", 

1098 icon_left="trash", 

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

1100 ) 

1101 

1102 form = HTML.tag( 

1103 "form", 

1104 method="post", 

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

1106 ref="deleteResultsForm", 

1107 class_="control", 

1108 c=[ 

1109 render_csrf_token(self.request), 

1110 button, 

1111 ], 

1112 ) 

1113 return form 

1114 

1115 ############################## 

1116 # version history methods 

1117 ############################## 

1118 

1119 @classmethod 

1120 def is_versioned(cls): 

1121 """ 

1122 Returns boolean indicating whether the model class is 

1123 configured for SQLAlchemy-Continuum versioning. 

1124 

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

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

1127 setting the ``model_is_versioned`` attribute:: 

1128 

1129 class WidgetView(MasterView): 

1130 model_class = Widget 

1131 model_is_versioned = False 

1132 

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

1134 

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

1136 ``False``. 

1137 """ 

1138 if hasattr(cls, "model_is_versioned"): 

1139 return cls.model_is_versioned 

1140 

1141 model_class = cls.get_model_class() 

1142 if hasattr(model_class, "__versioned__"): 

1143 return True 

1144 

1145 return False 

1146 

1147 @classmethod 

1148 def get_model_version_class(cls): 

1149 """ 

1150 Returns the version class for the master model class. 

1151 

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

1153 """ 

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

1155 

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

1157 

1158 def should_expose_versions(self): 

1159 """ 

1160 Returns boolean indicating whether versioning history should 

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

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

1163 

1164 * :meth:`is_versioned()` 

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

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

1167 

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

1169 user; else ``False``. 

1170 """ 

1171 if not self.is_versioned(): 

1172 return False 

1173 

1174 if not self.app.continuum_is_enabled(): 

1175 return False 

1176 

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

1178 return False 

1179 

1180 return True 

1181 

1182 def view_versions(self): 

1183 """ 

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

1185 :meth:`view_version()`. 

1186 

1187 This usually corresponds to a URL like 

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

1189 for the record. 

1190 

1191 By default, this view is included only if 

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

1193 

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

1195 record's version history. 

1196 

1197 See also: 

1198 

1199 * :meth:`make_version_grid()` 

1200 """ 

1201 instance = self.get_instance() 

1202 instance_title = self.get_instance_title(instance) 

1203 grid = self.make_version_grid(instance) 

1204 

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

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

1207 context = grid.get_vue_context() 

1208 if grid.paginated and grid.paginate_on_backend: 

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

1210 return self.json_response(context) 

1211 

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

1213 

1214 instance_link = tags.link_to( 

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

1216 ) 

1217 

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

1219 [index_link, instance_link] 

1220 ) 

1221 

1222 return self.render_to_response( 

1223 "view_versions", 

1224 { 

1225 "index_title_rendered": index_title_rendered, 

1226 "instance": instance, 

1227 "instance_title": instance_title, 

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

1229 "grid": grid, 

1230 }, 

1231 ) 

1232 

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

1234 """ 

1235 Create and return a grid for use with the 

1236 :meth:`view_versions()` view. 

1237 

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

1239 

1240 * :meth:`get_version_grid_key()` 

1241 * :meth:`get_version_grid_columns()` 

1242 * :meth:`get_version_grid_data()` 

1243 * :meth:`configure_version_grid()` 

1244 

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

1246 """ 

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

1248 

1249 route_prefix = self.get_route_prefix() 

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

1251 if not instance: 

1252 instance = self.get_instance() 

1253 

1254 if "key" not in kwargs: 

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

1256 

1257 if "model_class" not in kwargs: 

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

1259 

1260 if "columns" not in kwargs: 

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

1262 

1263 if "data" not in kwargs: 

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

1265 

1266 if "actions" not in kwargs: 

1267 route = f"{route_prefix}.version" 

1268 

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

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

1271 

1272 kwargs["actions"] = [ 

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

1274 ] 

1275 

1276 kwargs.setdefault("paginated", True) 

1277 

1278 grid = self.make_grid(**kwargs) 

1279 self.configure_version_grid(grid) 

1280 grid.load_settings() 

1281 return grid 

1282 

1283 @classmethod 

1284 def get_version_grid_key(cls): 

1285 """ 

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

1287 sort/filter options etc. 

1288 

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

1290 

1291 :returns: Grid key as string 

1292 """ 

1293 if hasattr(cls, "version_grid_key"): 

1294 return cls.version_grid_key 

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

1296 

1297 def get_version_grid_columns(self): 

1298 """ 

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

1300 :meth:`view_versions()` view. 

1301 

1302 This is normally called automatically by 

1303 :meth:`make_version_grid()`. 

1304 

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

1306 cases, or can override this method if needed. 

1307 

1308 :returns: List of string column names 

1309 """ 

1310 if hasattr(self, "version_grid_columns"): 

1311 return self.version_grid_columns 

1312 

1313 return [ 

1314 "id", 

1315 "issued_at", 

1316 "user", 

1317 "remote_addr", 

1318 "comment", 

1319 ] 

1320 

1321 def get_version_grid_data(self, instance): 

1322 """ 

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

1324 view. 

1325 

1326 This is normally called automatically by 

1327 :meth:`make_version_grid()`. 

1328 

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

1330 records which are associated with versions of the given model 

1331 instance. See also: 

1332 

1333 * :meth:`get_version_joins()` 

1334 * :meth:`normalize_version_joins()` 

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

1336 

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

1338 """ 

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

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

1341 model_transaction_query, 

1342 ) 

1343 

1344 model_class = self.get_model_class() 

1345 txncls = continuum.transaction_class(model_class) 

1346 query = model_transaction_query(instance, joins=self.normalize_version_joins()) 

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

1348 

1349 def get_version_joins(self): 

1350 """ 

1351 Override this method to declare additional version tables 

1352 which should be joined when showing the overall revision 

1353 history for a given model instance. 

1354 

1355 Note that whatever this method returns, will be ran through 

1356 :meth:`normalize_version_joins()` before being passed along to 

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

1358 

1359 :returns: List of version joins info as described below. 

1360 

1361 In the simple scenario where an "extension" table is involved, 

1362 e.g. a ``UserExtension`` table:: 

1363 

1364 def get_version_joins(self): 

1365 model = self.app.model 

1366 return super().get_version_joins() + [ 

1367 model.UserExtension, 

1368 ] 

1369 

1370 In the case where a secondary table is "related" to the main 

1371 model table, but not a standard extension (using the 

1372 ``User.person`` relationship as example):: 

1373 

1374 def get_version_joins(self): 

1375 model = self.app.model 

1376 return super().get_version_joins() + [ 

1377 (model.Person, "uuid", "person_uuid"), 

1378 ] 

1379 

1380 See also :meth:`get_version_grid_data()`. 

1381 """ 

1382 return [] 

1383 

1384 def normalize_version_joins(self): 

1385 """ 

1386 This method calls :meth:`get_version_joins()` and normalizes 

1387 the result, which will then get passed along to 

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

1389 

1390 Subclass should (generally) not override this, but instead 

1391 override :meth:`get_version_joins()`. 

1392 

1393 Each element in the return value (list) will be a 3-tuple 

1394 conforming to what is needed for the query function. 

1395 

1396 See also :meth:`get_version_grid_data()`. 

1397 

1398 :returns: List of version joins info. 

1399 """ 

1400 joins = [] 

1401 for join in self.get_version_joins(): 

1402 if not isinstance(join, tuple): 

1403 join = (join, "uuid", "uuid") 

1404 joins.append(join) 

1405 return joins 

1406 

1407 def configure_version_grid(self, g): 

1408 """ 

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

1410 

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

1412 

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

1414 """ 

1415 # id 

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

1417 # g.set_link("id") 

1418 

1419 # issued_at 

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

1421 g.set_link("issued_at") 

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

1423 

1424 # user 

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

1426 g.set_link("user") 

1427 

1428 # remote_addr 

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

1430 

1431 # comment 

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

1433 

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

1435 self, txn, key, value 

1436 ): 

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

1438 

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

1440 """ 

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

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

1443 

1444 This usually corresponds to a URL like 

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

1446 key/ID for the record and YYY represents a 

1447 SQLAlchemy-Continuum ``transaction.id``. 

1448 

1449 By default, this view is included only if 

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

1451 

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

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

1454 

1455 See also: 

1456 

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

1458 * :meth:`get_relevant_versions()` 

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

1460 """ 

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

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

1463 model_transaction_query, 

1464 ) 

1465 

1466 instance = self.get_instance() 

1467 model_class = self.get_model_class() 

1468 route_prefix = self.get_route_prefix() 

1469 txncls = continuum.transaction_class(model_class) 

1470 transactions = model_transaction_query( 

1471 instance, joins=self.normalize_version_joins() 

1472 ) 

1473 

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

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

1476 if not txn: 

1477 raise self.notfound() 

1478 

1479 prev_url = None 

1480 older = ( 

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

1482 .filter(txncls.id != txnid) 

1483 .order_by(txncls.issued_at.desc()) 

1484 .first() 

1485 ) 

1486 if older: 

1487 prev_url = self.request.route_url( 

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

1489 ) 

1490 

1491 next_url = None 

1492 newer = ( 

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

1494 .filter(txncls.id != txnid) 

1495 .order_by(txncls.issued_at) 

1496 .first() 

1497 ) 

1498 if newer: 

1499 next_url = self.request.route_url( 

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

1501 ) 

1502 

1503 version_diffs = [ 

1504 VersionDiff(self.config, version) 

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

1506 ] 

1507 

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

1509 

1510 instance_title = self.get_instance_title(instance) 

1511 instance_link = tags.link_to( 

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

1513 ) 

1514 

1515 history_link = tags.link_to( 

1516 "history", 

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

1518 ) 

1519 

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

1521 [index_link, instance_link, history_link] 

1522 ) 

1523 

1524 return self.render_to_response( 

1525 "view_version", 

1526 { 

1527 "index_title_rendered": index_title_rendered, 

1528 "instance": instance, 

1529 "instance_title": instance_title, 

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

1531 "transaction": txn, 

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

1533 "version_diffs": version_diffs, 

1534 "show_prev_next": True, 

1535 "prev_url": prev_url, 

1536 "next_url": next_url, 

1537 }, 

1538 ) 

1539 

1540 def get_relevant_versions(self, transaction, instance): 

1541 """ 

1542 Should return all version records pertaining to the given 

1543 model instance and transaction. 

1544 

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

1546 

1547 :param transaction: SQLAlchemy-Continuum ``transaction`` 

1548 record/instance. 

1549 

1550 :param instance: Instance of the model class. 

1551 

1552 :returns: List of version records. 

1553 """ 

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

1555 

1556 session = self.Session() 

1557 vercls = self.get_model_version_class() 

1558 versions = [] 

1559 

1560 # first get all versions for the model instance proper 

1561 versions.extend( 

1562 session.query(vercls) 

1563 .filter(vercls.transaction == transaction) 

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

1565 .all() 

1566 ) 

1567 

1568 # then get all related versions, per declared joins 

1569 for child_class, foreign_attr, primary_attr in self.normalize_version_joins(): 

1570 child_vercls = continuum.version_class(child_class) 

1571 versions.extend( 

1572 session.query(child_vercls) 

1573 .filter(child_vercls.transaction == transaction) 

1574 .filter( 

1575 getattr(child_vercls, foreign_attr) 

1576 == getattr(instance, primary_attr) 

1577 ) 

1578 ) 

1579 

1580 return versions 

1581 

1582 ############################## 

1583 # autocomplete methods 

1584 ############################## 

1585 

1586 def autocomplete(self): 

1587 """ 

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

1589 list of autocomplete results to match. 

1590 

1591 By default, this view is included only if 

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

1593 like ``/widgets/autocomplete``. 

1594 

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

1596 rather should override the others which this calls: 

1597 

1598 * :meth:`autocomplete_data()` 

1599 * :meth:`autocomplete_normalize()` 

1600 """ 

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

1602 if not term: 

1603 return [] 

1604 

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

1606 if not data: 

1607 return [] 

1608 

1609 max_results = 100 # TODO 

1610 

1611 results = [] 

1612 for obj in data[:max_results]: 

1613 normal = self.autocomplete_normalize(obj) 

1614 if normal: 

1615 results.append(normal) 

1616 

1617 return results 

1618 

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

1620 """ 

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

1622 based on autocomplete search term. This is called by 

1623 :meth:`autocomplete()`. 

1624 

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

1626 

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

1628 

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

1630 """ 

1631 return None 

1632 

1633 def autocomplete_normalize(self, obj): 

1634 """ 

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

1636 record, suitable for autocomplete JSON results. This is 

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

1638 

1639 Subclass may need to override this; default logic is 

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

1641 "autocomplete results" dict for the object:: 

1642 

1643 { 

1644 'value': obj.uuid, 

1645 'label': str(obj), 

1646 } 

1647 

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

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

1650 

1651 :param obj: Model record/instance. 

1652 

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

1654 above. 

1655 """ 

1656 return { 

1657 "value": obj.uuid, 

1658 "label": str(obj), 

1659 } 

1660 

1661 ############################## 

1662 # download methods 

1663 ############################## 

1664 

1665 def download(self): 

1666 """ 

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

1668 

1669 This usually corresponds to a URL like 

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

1671 for the record. 

1672 

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

1674 is true. 

1675 

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

1677 it as a file download response to the client. 

1678 

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

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

1681 files associated with the model record. This filename is 

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

1683 

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

1685 

1686 Subclass normally should not override this method, but rather 

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

1688 this one: 

1689 

1690 * :meth:`download_path()` 

1691 """ 

1692 obj = self.get_instance() 

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

1694 

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

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

1697 return self.notfound() 

1698 

1699 return self.file_response(path) 

1700 

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

1702 """ 

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

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

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

1706 

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

1708 

1709 :param obj: Refefence to the model instance. 

1710 

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

1712 

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

1714 

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

1716 file path should be returned, if applicable. 

1717 

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

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

1720 response. 

1721 """ 

1722 return None 

1723 

1724 ############################## 

1725 # execute methods 

1726 ############################## 

1727 

1728 def execute(self): 

1729 """ 

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

1731 

1732 This usually corresponds to a URL like 

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

1734 for the record. 

1735 

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

1737 true. 

1738 

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

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

1741 

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

1743 * batches (not yet implemented; 

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

1745 Manual) 

1746 

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

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

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

1750 live" the data held within the batch. 

1751 

1752 Subclass normally should not override this method, but rather 

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

1754 this one: 

1755 

1756 * :meth:`execute_instance()` 

1757 """ 

1758 route_prefix = self.get_route_prefix() 

1759 model_title = self.get_model_title() 

1760 obj = self.get_instance() 

1761 

1762 # make the progress tracker 

1763 progress = self.make_progress( 

1764 f"{route_prefix}.execute", 

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

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

1767 ) 

1768 

1769 # start thread for execute; show progress page 

1770 key = self.request.matchdict 

1771 thread = threading.Thread( 

1772 target=self.execute_thread, 

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

1774 kwargs={"progress": progress}, 

1775 ) 

1776 thread.start() 

1777 return self.render_progress( 

1778 progress, 

1779 context={ 

1780 "instance": obj, 

1781 }, 

1782 template=self.execute_progress_template, 

1783 ) 

1784 

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

1786 """ 

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

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

1789 

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

1791 

1792 :param obj: Reference to the model instance. 

1793 

1794 :param user: Reference to the 

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

1796 is doing the execute. 

1797 

1798 :param progress: Optional progress indicator factory. 

1799 """ 

1800 

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

1802 self, key, user_uuid, progress=None 

1803 ): 

1804 """ """ 

1805 model = self.app.model 

1806 model_title = self.get_model_title() 

1807 

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

1809 session = self.app.make_session() 

1810 

1811 # fetch model instance and user for this session 

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

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

1814 

1815 try: 

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

1817 

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

1819 session.rollback() 

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

1821 if progress: 

1822 progress.handle_error(error) 

1823 

1824 else: 

1825 session.commit() 

1826 if progress: 

1827 progress.handle_success() 

1828 

1829 finally: 

1830 session.close() 

1831 

1832 ############################## 

1833 # configure methods 

1834 ############################## 

1835 

1836 def configure(self, session=None): 

1837 """ 

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

1839 this master view and/or model. 

1840 

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

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

1843 

1844 The expected workflow is as follows: 

1845 

1846 * user navigates to Configure page 

1847 * user modifies settings and clicks Save 

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

1849 * then it saves user-submitted settings 

1850 

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

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

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

1854 

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

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

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

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

1859 

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

1861 only provide their basic definitions via 

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

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

1864 to achieve the aim(s). 

1865 

1866 See also related methods, used by this one: 

1867 

1868 * :meth:`configure_get_simple_settings()` 

1869 * :meth:`configure_get_context()` 

1870 * :meth:`configure_gather_settings()` 

1871 * :meth:`configure_remove_settings()` 

1872 * :meth:`configure_save_settings()` 

1873 """ 

1874 self.configuring = True 

1875 config_title = self.get_config_title() 

1876 

1877 # was form submitted? 

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

1879 

1880 # maybe just remove settings 

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

1882 self.configure_remove_settings(session=session) 

1883 self.request.session.flash( 

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

1885 ) 

1886 

1887 # reload configure page 

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

1889 

1890 # gather/save settings 

1891 data = get_form_data(self.request) 

1892 settings = self.configure_gather_settings(data) 

1893 self.configure_remove_settings(session=session) 

1894 self.configure_save_settings(settings, session=session) 

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

1896 

1897 # reload configure page 

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

1899 

1900 # render configure page 

1901 context = self.configure_get_context() 

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

1903 

1904 def configure_get_context( 

1905 self, 

1906 simple_settings=None, 

1907 ): 

1908 """ 

1909 Returns the full context dict, for rendering the 

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

1911 

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

1913 to just name/value). 

1914 

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

1916 "complex" settings etc. 

1917 

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

1919 already initialized. Otherwise it is retrieved via 

1920 :meth:`configure_get_simple_settings()`. 

1921 

1922 :returns: Context dict for the page template. 

1923 """ 

1924 context = {} 

1925 

1926 # simple settings 

1927 if simple_settings is None: 

1928 simple_settings = self.configure_get_simple_settings() 

1929 if simple_settings: 

1930 

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

1932 normalized = {} 

1933 for simple in simple_settings: 

1934 

1935 # name 

1936 name = simple["name"] 

1937 

1938 # value 

1939 if "value" in simple: 

1940 value = simple["value"] 

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

1942 value = self.config.get_bool( 

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

1944 ) 

1945 else: 

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

1947 

1948 normalized[name] = value 

1949 

1950 # add to template context 

1951 context["simple_settings"] = normalized 

1952 

1953 return context 

1954 

1955 def configure_get_simple_settings(self): 

1956 """ 

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

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

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

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

1961 part of this method's return value.) 

1962 

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

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

1965 

1966 The setting definitions returned must each be a dict of 

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

1968 setting might be:: 

1969 

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

1971 

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

1973 is a more complete example:: 

1974 

1975 { 

1976 'name': 'wutta.production', 

1977 'type': bool, 

1978 'default': False, 

1979 'save_if_empty': False, 

1980 } 

1981 

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

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

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

1985 

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

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

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

1989 

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

1991 Note that their order does not matter since the template 

1992 must explicitly define field layout etc. 

1993 """ 

1994 return [] 

1995 

1996 def configure_gather_settings( 

1997 self, 

1998 data, 

1999 simple_settings=None, 

2000 ): 

2001 """ 

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

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

2004 

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

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

2007 the simple setting definitions. 

2008 

2009 Subclass may need to override this method if complex settings 

2010 are required. 

2011 

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

2013 

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

2015 already initialized. Otherwise it is retrieved via 

2016 :meth:`configure_get_simple_settings()`. 

2017 

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

2019 in spirit to the definition syntax used in 

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

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

2022 

2023 { 

2024 'name': 'wutta.app_title', 

2025 'value': 'Wutta Wutta', 

2026 } 

2027 

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

2029 

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

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

2032 contain all of them. 

2033 

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

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

2036 saved to DB) unless the setting definition has the 

2037 ``save_if_empty`` flag set. 

2038 """ 

2039 settings = [] 

2040 

2041 # simple settings 

2042 if simple_settings is None: 

2043 simple_settings = self.configure_get_simple_settings() 

2044 if simple_settings: 

2045 

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

2047 for simple in simple_settings: 

2048 name = simple["name"] 

2049 

2050 if name in data: 

2051 value = data[name] 

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

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

2054 value = False 

2055 else: 

2056 value = simple.get("default") 

2057 

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

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

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

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

2062 elif value is None: 

2063 value = "" 

2064 else: 

2065 value = str(value) 

2066 

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

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

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

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

2071 

2072 return settings 

2073 

2074 def configure_remove_settings( 

2075 self, 

2076 simple_settings=None, 

2077 session=None, 

2078 ): 

2079 """ 

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

2081 :meth:`configure()`. 

2082 

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

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

2085 

2086 The default logic can handle this automatically for simple 

2087 settings; subclass must override for any complex settings. 

2088 

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

2090 already initialized. Otherwise it is retrieved via 

2091 :meth:`configure_get_simple_settings()`. 

2092 """ 

2093 names = [] 

2094 

2095 # simple settings 

2096 if simple_settings is None: 

2097 simple_settings = self.configure_get_simple_settings() 

2098 if simple_settings: 

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

2100 

2101 if names: 

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

2103 # point to our primary app DB 

2104 session = session or self.Session() 

2105 for name in names: 

2106 self.app.delete_setting(session, name) 

2107 

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

2109 """ 

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

2111 :meth:`configure()`. 

2112 

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

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

2115 

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

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

2118 """ 

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

2120 # to our primary app DB 

2121 session = session or self.Session() 

2122 for setting in settings: 

2123 self.app.save_setting( 

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

2125 ) 

2126 

2127 ############################## 

2128 # grid rendering methods 

2129 ############################## 

2130 

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

2132 """ 

2133 Custom grid value renderer for "boolean" fields. 

2134 

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

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

2137 To use this feature for your grid:: 

2138 

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

2140 """ 

2141 if value is None: 

2142 return None 

2143 

2144 return "Yes" if value else "No" 

2145 

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

2147 """ 

2148 Custom grid value renderer for "currency" fields. 

2149 

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

2151 decimal as appropriate, and add the currency symbol. 

2152 

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

2154 default is 2 places. 

2155 

2156 To use this feature for your grid:: 

2157 

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

2159 

2160 # you can also override scale 

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

2162 """ 

2163 

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

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

2166 value = record[key] 

2167 

2168 if value is None: 

2169 return None 

2170 

2171 if value < 0: 

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

2173 return fmt.format(0 - value) 

2174 

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

2176 return fmt.format(value) 

2177 

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

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

2180 ): 

2181 """ """ 

2182 warnings.warn( 

2183 "MasterView.grid_render_datetime() is deprecated; " 

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

2185 DeprecationWarning, 

2186 stacklevel=2, 

2187 ) 

2188 

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

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

2191 value = record[key] 

2192 

2193 if value is None: 

2194 return None 

2195 

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

2197 

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

2199 """ 

2200 Custom grid value renderer for "enum" fields. 

2201 

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

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

2204 

2205 To use this feature for your grid:: 

2206 

2207 from enum import Enum 

2208 

2209 class MyEnum(Enum): 

2210 ONE = 1 

2211 TWO = 2 

2212 THREE = 3 

2213 

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

2215 """ 

2216 if enum: 

2217 original = record[key] 

2218 if original: 

2219 return original.name 

2220 

2221 return value 

2222 

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

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

2225 ): 

2226 """ 

2227 Custom grid value renderer for "notes" fields. 

2228 

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

2230 characters, it is returned as-is. 

2231 

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

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

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

2235 mouse hover. 

2236 

2237 To use this feature for your grid:: 

2238 

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

2240 

2241 # you can also override maxlen 

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

2243 """ 

2244 if value is None: 

2245 return None 

2246 

2247 if len(value) < maxlen: 

2248 return value 

2249 

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

2251 

2252 ############################## 

2253 # support methods 

2254 ############################## 

2255 

2256 def get_class_hierarchy(self, topfirst=True): 

2257 """ 

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

2259 class inherits. 

2260 

2261 This is a wrapper around 

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

2263 """ 

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

2265 

2266 def has_perm(self, name): 

2267 """ 

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

2269 

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

2271 ``name`` before passing it on to 

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

2273 

2274 For instance within the 

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

2276 result:: 

2277 

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

2279 

2280 self.has_perm('edit') 

2281 

2282 So this shortcut only applies to permissions defined for the 

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

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

2285 different prefix). 

2286 """ 

2287 permission_prefix = self.get_permission_prefix() 

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

2289 

2290 def has_any_perm(self, *names): 

2291 """ 

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

2293 permissions. 

2294 

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

2296 none do, returns ``False``. 

2297 """ 

2298 for name in names: 

2299 if self.has_perm(name): 

2300 return True 

2301 return False 

2302 

2303 def make_button( 

2304 self, 

2305 label, 

2306 variant=None, 

2307 primary=False, 

2308 url=None, 

2309 **kwargs, 

2310 ): 

2311 """ 

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

2313 

2314 :param label: Text label for the button. 

2315 

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

2317 for the button. Buefy and Oruga represent this differently 

2318 but this logic expects the Buefy format 

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

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

2321 terminology. 

2322 

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

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

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

2326 

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

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

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

2330 

2331 This is the preferred method where applicable, since it 

2332 avoids the Buefy vs. Oruga confusion, and the 

2333 implementation can change in the future. 

2334 

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

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

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

2338 

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

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

2341 attributes on the button tag. 

2342 

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

2344 reasons. 

2345 

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

2347 along the lines of: 

2348 

2349 .. code-block:: 

2350 

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

2352 icon-pack="fas" 

2353 icon-left="hand-pointer"> 

2354 Click Me 

2355 </b-button> 

2356 """ 

2357 btn_kw = kwargs 

2358 btn_kw.setdefault("c", label) 

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

2360 

2361 if "type" not in btn_kw: 

2362 if variant: 

2363 btn_kw["type"] = variant 

2364 elif primary: 

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

2366 

2367 if url: 

2368 btn_kw["href"] = url 

2369 

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

2371 

2372 if url: 

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

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

2375 # we patch that into place manually 

2376 button = str(button) 

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

2378 button = HTML.literal(button) 

2379 

2380 return button 

2381 

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

2383 """ 

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

2385 when viewing the given object. 

2386 

2387 Default logic always returns empty list; subclass can override 

2388 as needed. 

2389 

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

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

2392 see/use. 

2393 

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

2395 

2396 def get_xref_buttons(self, product): 

2397 buttons = [] 

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

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

2400 id=product.external_id) 

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

2402 return buttons 

2403 """ 

2404 return [] 

2405 

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

2407 """ 

2408 Create and return a 

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

2410 given key. 

2411 

2412 This is normally done just before calling 

2413 :meth:`render_progress()`. 

2414 """ 

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

2416 

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

2418 """ 

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

2420 

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

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

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

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

2425 is redirected to the final destination. 

2426 

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

2428 

2429 :param progress: Progress indicator instance as returned by 

2430 :meth:`make_progress()`. 

2431 

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

2433 """ 

2434 template = template or "/progress.mako" 

2435 context = context or {} 

2436 context["progress"] = progress 

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

2438 

2439 def render_to_response(self, template, context): 

2440 """ 

2441 Locate and render an appropriate template, with the given 

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

2443 

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

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

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

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

2448 :meth:`get_fallback_templates()`. 

2449 

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

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

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

2453 

2454 * ``/widgets/edit.mako`` 

2455 * ``/master/edit.mako`` 

2456 

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

2458 It then calls 

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

2460 returns the result. 

2461 

2462 :param template: Base name for the template. 

2463 

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

2465 

2466 :returns: Response object containing the rendered template. 

2467 """ 

2468 defaults = { 

2469 "master": self, 

2470 "route_prefix": self.get_route_prefix(), 

2471 "index_title": self.get_index_title(), 

2472 "index_url": self.get_index_url(), 

2473 "model_title": self.get_model_title(), 

2474 "model_title_plural": self.get_model_title_plural(), 

2475 "config_title": self.get_config_title(), 

2476 } 

2477 

2478 # merge defaults + caller-provided context 

2479 defaults.update(context) 

2480 context = defaults 

2481 

2482 # add crud flags if we have an instance 

2483 if "instance" in context: 

2484 instance = context["instance"] 

2485 if "instance_title" not in context: 

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

2487 if "instance_editable" not in context: 

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

2489 if "instance_deletable" not in context: 

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

2491 

2492 # supplement context further if needed 

2493 context = self.get_template_context(context) 

2494 

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

2496 page_templates = self.get_page_templates(template) 

2497 mako_path = page_templates[0] 

2498 try: 

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

2500 except IOError: 

2501 

2502 # failing that, try one or more fallback templates 

2503 for fallback in page_templates[1:]: 

2504 try: 

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

2506 except IOError: 

2507 pass 

2508 

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

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

2511 # let that error raise on up 

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

2513 

2514 def get_template_context(self, context): 

2515 """ 

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

2517 the current view template. 

2518 

2519 Default logic for this method returns the given context 

2520 unchanged. 

2521 

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

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

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

2525 context only for certain view templates. 

2526 

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

2528 auto-provided by the master view logic. 

2529 

2530 :returns: Final context dict for the template. 

2531 """ 

2532 return context 

2533 

2534 def get_page_templates(self, template): 

2535 """ 

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

2537 render the current page. This is called by 

2538 :meth:`render_to_response()`. 

2539 

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

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

2542 entries becoming more generic. 

2543 

2544 In practice this method defines the first entry but calls 

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

2546 

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

2548 ``'view'``. 

2549 

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

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

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

2553 

2554 [ 

2555 '/widgets/view.mako', 

2556 '/master/view.mako', 

2557 ] 

2558 

2559 """ 

2560 template_prefix = self.get_template_prefix() 

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

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

2563 return page_templates 

2564 

2565 def get_fallback_templates(self, template): 

2566 """ 

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

2568 attempted for rendering the current page. See also 

2569 :meth:`get_page_templates()`. 

2570 

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

2572 ``'view'``. 

2573 

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

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

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

2577 

2578 ['/master/view.mako'] 

2579 """ 

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

2581 

2582 def get_index_title(self): 

2583 """ 

2584 Returns the main index title for the master view. 

2585 

2586 By default this returns the value from 

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

2588 needed. 

2589 """ 

2590 return self.get_model_title_plural() 

2591 

2592 def get_index_url(self, **kwargs): 

2593 """ 

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

2595 

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

2597 """ 

2598 if self.listable: 

2599 route_prefix = self.get_route_prefix() 

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

2601 return None 

2602 

2603 def set_labels(self, obj): 

2604 """ 

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

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

2607 

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

2609 :meth:`configure_form()`. 

2610 

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

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

2613 

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

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

2616 

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

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

2619 """ 

2620 labels = self.collect_labels() 

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

2622 obj.set_label(key, label) 

2623 

2624 def collect_labels(self): 

2625 """ 

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

2627 

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

2629 like so:: 

2630 

2631 from wuttaweb.views import MasterView 

2632 

2633 class WidgetView(MasterView): 

2634 

2635 labels = { 

2636 'id': "Widget ID", 

2637 'serial_no': "Serial Number", 

2638 } 

2639 

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

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

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

2643 wins. 

2644 

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

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

2647 

2648 :returns: Dict of all labels found. 

2649 """ 

2650 labels = {} 

2651 hierarchy = self.get_class_hierarchy() 

2652 for cls in hierarchy: 

2653 if hasattr(cls, "labels"): 

2654 labels.update(cls.labels) 

2655 return labels 

2656 

2657 def make_model_grid( 

2658 self, session=None, **kwargs 

2659 ): # pylint: disable=too-many-branches,too-many-statements 

2660 """ 

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

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

2663 

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

2665 

2666 * :meth:`get_grid_key()` 

2667 * :meth:`get_grid_columns()` 

2668 * :meth:`get_grid_data()` 

2669 * :meth:`configure_grid()` 

2670 """ 

2671 route_prefix = self.get_route_prefix() 

2672 

2673 if "key" not in kwargs: 

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

2675 

2676 if "model_class" not in kwargs: 

2677 model_class = self.get_model_class() 

2678 if model_class: 

2679 kwargs["model_class"] = model_class 

2680 

2681 if "columns" not in kwargs: 

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

2683 

2684 if "data" not in kwargs: 

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

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

2687 ) 

2688 

2689 if "actions" not in kwargs: 

2690 actions = [] 

2691 

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

2693 

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

2695 actions.append( 

2696 self.make_grid_action( 

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

2698 ) 

2699 ) 

2700 

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

2702 actions.append( 

2703 self.make_grid_action( 

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

2705 ) 

2706 ) 

2707 

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

2709 actions.append( 

2710 self.make_grid_action( 

2711 "delete", 

2712 icon="trash", 

2713 url=self.get_action_url_delete, 

2714 link_class="has-text-danger", 

2715 ) 

2716 ) 

2717 

2718 kwargs["actions"] = actions 

2719 

2720 mergeable = self.mergeable and self.has_perm("merge") 

2721 

2722 if "tools" not in kwargs: 

2723 tools = [] 

2724 

2725 # show totals 

2726 if self.has_grid_totals: 

2727 button = self.make_button( 

2728 "{{ gridTotalsFetching ? 'Working, please wait...' : 'Show Totals' }}", 

2729 icon_left="calculator", 

2730 **{ 

2731 "v-if": "!gridTotalsHTML.length", 

2732 ":disabled": "gridTotalsFetching", 

2733 "@click": "gridTotalsFetch()", 

2734 }, 

2735 ) 

2736 display = HTML.tag( 

2737 "div", 

2738 class_="control", 

2739 style="margin: 0 0.5rem;", 

2740 **{ 

2741 "v-if": "gridTotalsHTML.length", 

2742 "v-html": "gridTotalsHTML", 

2743 }, 

2744 ) 

2745 wrapper = HTML.tag("div", c=[button, display]) 

2746 tools.append(("show-totals", wrapper)) 

2747 

2748 # delete-bulk 

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

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

2751 

2752 # merge 

2753 if mergeable: 

2754 hidden = tags.hidden("uuids", **{":value": "checkedUUIDs"}) 

2755 button = self.make_button( 

2756 '{{ mergeSubmitting ? "Working, please wait..." : "Merge 2 records" }}', 

2757 primary=True, 

2758 native_type="submit", 

2759 icon_left="object-ungroup", 

2760 **{":disabled": "mergeSubmitting || checkedRows.length != 2"}, 

2761 ) 

2762 csrf = render_csrf_token(self.request) 

2763 html = ( 

2764 tags.form( 

2765 self.request.route_url(f"{route_prefix}.merge"), 

2766 **{"@submit": "mergeSubmitting = true"}, 

2767 ) 

2768 + csrf 

2769 + hidden 

2770 + button 

2771 + tags.end_form() 

2772 ) 

2773 tools.append(("merge", html)) 

2774 

2775 kwargs["tools"] = tools 

2776 

2777 kwargs.setdefault("checkable", self.checkable or mergeable) 

2778 if hasattr(self, "grid_row_class"): 

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

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

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

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

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

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

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

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

2787 

2788 grid = self.make_grid(**kwargs) 

2789 self.configure_grid(grid) 

2790 grid.load_settings() 

2791 return grid 

2792 

2793 def get_grid_columns(self): 

2794 """ 

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

2796 :meth:`index()` view. 

2797 

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

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

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

2801 

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

2803 (try to) generate its own default list. 

2804 

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

2806 can override this method if needed. 

2807 

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

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

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

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

2812 remove or replace some of those within 

2813 :meth:`configure_grid()`. 

2814 """ 

2815 if hasattr(self, "grid_columns"): 

2816 return self.grid_columns 

2817 return None 

2818 

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

2820 self, columns=None, session=None 

2821 ): 

2822 """ 

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

2824 

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

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

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

2828 

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

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

2831 empty list. Subclass should override as needed. 

2832 """ 

2833 query = self.get_query(session=session) 

2834 if query: 

2835 return query 

2836 return [] 

2837 

2838 def get_query(self, session=None): 

2839 """ 

2840 Returns the main SQLAlchemy query object for the 

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

2842 :meth:`get_grid_data()`. 

2843 

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

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

2846 """ 

2847 model_class = self.get_model_class() 

2848 if model_class: 

2849 session = session or self.Session() 

2850 return session.query(model_class) 

2851 return None 

2852 

2853 def configure_grid(self, grid): 

2854 """ 

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

2856 

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

2858 

2859 There is minimal default logic here; subclass should override 

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

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

2862 based on request details etc. 

2863 """ 

2864 if "uuid" in grid.columns: 

2865 grid.columns.remove("uuid") 

2866 

2867 self.set_labels(grid) 

2868 

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

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

2871 # for key in self.get_model_key(): 

2872 # grid.set_link(key) 

2873 

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

2875 """ 

2876 This should return the appropriate model instance, based on 

2877 the ``matchdict`` of model keys. 

2878 

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

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

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

2882 (route/params). 

2883 

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

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

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

2887 

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

2889 obj = self.get_instance(matchdict=keys) 

2890 

2891 Although some models may have different, possibly composite 

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

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

2894 

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

2896 raise a 404 error, 

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

2898 

2899 Default implementation of this method should work okay for 

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

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

2902 may need to define. 

2903 

2904 .. warning:: 

2905 

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

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

2908 

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

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

2911 

2912 def get_instance(self, **kwargs): 

2913 

2914 # ..try to locate instance.. 

2915 obj = self.locate_instance_somehow() 

2916 

2917 if not obj: 

2918 

2919 # NB. THIS MAY NOT WORK AS EXPECTED 

2920 #return self.notfound() 

2921 

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

2923 raise self.notfound() 

2924 

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

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

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

2928 will kick in and control flow goes elsewhere. 

2929 """ 

2930 model_class = self.get_model_class() 

2931 if model_class: 

2932 session = session or self.Session() 

2933 matchdict = matchdict or self.request.matchdict 

2934 

2935 def filtr(query, model_key): 

2936 key = matchdict[model_key] 

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

2938 return query 

2939 

2940 query = session.query(model_class) 

2941 

2942 for key in self.get_model_key(): 

2943 query = filtr(query, key) 

2944 

2945 try: 

2946 return query.one() 

2947 except orm.exc.NoResultFound: 

2948 pass 

2949 

2950 raise self.notfound() 

2951 

2952 raise NotImplementedError( 

2953 "you must define get_instance() method " 

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

2955 ) 

2956 

2957 def get_instance_title(self, instance): 

2958 """ 

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

2960 in the page title when viewing etc. 

2961 

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

2963 subclass may override if needed. 

2964 """ 

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

2966 

2967 def get_action_route_kwargs(self, obj): 

2968 """ 

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

2970 

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

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

2973 

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

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

2976 the object. 

2977 

2978 :param obj: Model instance object. 

2979 

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

2981 """ 

2982 try: 

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

2984 except TypeError: 

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

2986 

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

2988 """ 

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

2990 

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

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

2993 

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

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

2996 returns the result. 

2997 

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

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

3000 

3001 :param obj: Model instance object. 

3002 

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

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

3005 """ 

3006 kw = self.get_action_route_kwargs(obj) 

3007 kw.update(kwargs) 

3008 route_prefix = self.get_route_prefix() 

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

3010 

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

3012 """ 

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

3014 

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

3016 represents the object's key/ID. 

3017 

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

3019 """ 

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

3021 

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

3023 """ 

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

3025 applicable. 

3026 

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

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

3029 

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

3031 this method will return ``None``. 

3032 

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

3034 """ 

3035 if self.is_editable(obj): 

3036 return self.get_action_url("edit", obj) 

3037 return None 

3038 

3039 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument 

3040 """ 

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

3042 applicable. 

3043 

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

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

3046 

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

3048 this method will return ``None``. 

3049 

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

3051 """ 

3052 if self.is_deletable(obj): 

3053 return self.get_action_url("delete", obj) 

3054 return None 

3055 

3056 def is_editable(self, obj): # pylint: disable=unused-argument 

3057 """ 

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

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

3060 

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

3062 if needed. 

3063 

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

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

3066 """ 

3067 return True 

3068 

3069 def is_deletable(self, obj): # pylint: disable=unused-argument 

3070 """ 

3071 Returns a boolean indicating whether "delete" should be 

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

3073 

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

3075 if needed. 

3076 

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

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

3079 """ 

3080 return True 

3081 

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

3083 """ 

3084 Make a form for the "model" represented by this subclass. 

3085 

3086 This method is normally called by all CRUD views: 

3087 

3088 * :meth:`create()` 

3089 * :meth:`view()` 

3090 * :meth:`edit()` 

3091 * :meth:`delete()` 

3092 

3093 The form need not have a ``model_instance``, as in the case of 

3094 :meth:`create()`. And it can be readonly as in the case of 

3095 :meth:`view()` and :meth:`delete()`. 

3096 

3097 If ``fields`` are not provided, :meth:`get_form_fields()` is 

3098 called. Usually a subclass will define :attr:`form_fields` 

3099 but it's only required if :attr:`model_class` is not set. 

3100 

3101 Then :meth:`configure_form()` is called, so subclass can go 

3102 crazy with that as needed. 

3103 

3104 :param model_instance: Model instance/record with which to 

3105 initialize the form data. Not needed for "create" forms. 

3106 

3107 :param fields: Optional fields list for the form. 

3108 

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

3110 """ 

3111 if "model_class" not in kwargs: 

3112 model_class = self.get_model_class() 

3113 if model_class: 

3114 kwargs["model_class"] = model_class 

3115 

3116 kwargs["model_instance"] = model_instance 

3117 

3118 if not fields: 

3119 fields = self.get_form_fields() 

3120 if fields: 

3121 kwargs["fields"] = fields 

3122 

3123 form = self.make_form(**kwargs) 

3124 self.configure_form(form) 

3125 return form 

3126 

3127 def get_form_fields(self): 

3128 """ 

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

3130 

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

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

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

3134 

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

3136 (try to) generate its own default list. 

3137 

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

3139 can override this method if needed. 

3140 

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

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

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

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

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

3146 """ 

3147 if hasattr(self, "form_fields"): 

3148 return self.form_fields 

3149 return None 

3150 

3151 def configure_form(self, form): 

3152 """ 

3153 Configure the given model form, as needed. 

3154 

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

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

3157 

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

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

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

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

3162 values for a record. 

3163 

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

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

3166 can further modify it based on request details etc. 

3167 """ 

3168 form.remove("uuid") 

3169 

3170 self.set_labels(form) 

3171 

3172 # mark key fields as readonly to prevent edit. see also 

3173 # related comments in the objectify() method 

3174 if self.editing: 

3175 for key in self.get_model_key(): 

3176 form.set_readonly(key) 

3177 

3178 def objectify(self, form): 

3179 """ 

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

3181 validated form data. 

3182 

3183 In simple cases this may just return the 

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

3185 

3186 When dealing with SQLAlchemy models it would return a proper 

3187 mapped instance, creating it if necessary. 

3188 

3189 This is called by various other form-saving methods: 

3190 

3191 * :meth:`save_create_form()` 

3192 * :meth:`save_edit_form()` 

3193 * :meth:`create_row_save_form()` 

3194 

3195 See also :meth:`persist()`. 

3196 

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

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

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

3200 the data. 

3201 """ 

3202 

3203 # ColanderAlchemy schema has an objectify() method which will 

3204 # return a populated model instance 

3205 schema = form.get_schema() 

3206 if hasattr(schema, "objectify"): 

3207 return schema.objectify(form.validated, context=form.model_instance) 

3208 

3209 # at this point we likely have no model class, so have to 

3210 # assume we're operating on a simple dict record. we (mostly) 

3211 # want to return that as-is, unless subclass overrides. 

3212 data = dict(form.validated) 

3213 

3214 # nb. we have a unique scenario when *editing* for a simple 

3215 # dict record (no model class). we mark the key fields as 

3216 # readonly in configure_form(), so they aren't part of the 

3217 # data here, but we need to add them back for sake of 

3218 # e.g. generating the 'view' route kwargs for redirect. 

3219 if self.editing: 

3220 obj = self.get_instance() 

3221 for key in self.get_model_key(): 

3222 if key not in data: 

3223 data[key] = obj[key] 

3224 

3225 return data 

3226 

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

3228 """ 

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

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

3231 

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

3233 a model instance which already reflects the validated form 

3234 data. 

3235 

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

3237 override if needed. 

3238 

3239 :param obj: Model instance object as produced by 

3240 :meth:`objectify()`. 

3241 

3242 See also :meth:`save_create_form()` and 

3243 :meth:`save_edit_form()`, which call this method. 

3244 """ 

3245 model = self.app.model 

3246 model_class = self.get_model_class() 

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

3248 

3249 # add sqlalchemy model to session 

3250 session = session or self.Session() 

3251 session.add(obj) 

3252 

3253 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments 

3254 self, func, args, kwargs, onerror=None, session=None, progress=None 

3255 ): 

3256 """ 

3257 Generic method to invoke for thread operations. 

3258 

3259 :param func: Callable which performs the actual logic. This 

3260 will be wrapped with a try/except statement for error 

3261 handling. 

3262 

3263 :param args: Tuple of positional arguments to pass to the 

3264 ``func`` callable. 

3265 

3266 :param kwargs: Dict of keyword arguments to pass to the 

3267 ``func`` callable. 

3268 

3269 :param onerror: Optional callback to invoke if ``func`` raises 

3270 an error. It should not expect any arguments. 

3271 

3272 :param session: Optional :term:`db session` in effect. Note 

3273 that if supplied, it will be *committed* (or rolled back on 

3274 error) and *closed* by this method. If you need more 

3275 specialized handling, do not use this method (or don't 

3276 specify the ``session``). 

3277 

3278 :param progress: Optional progress factory. If supplied, this 

3279 is assumed to be a 

3280 :class:`~wuttaweb.progress.SessionProgress` instance, and 

3281 it will be updated per success or failure of ``func`` 

3282 invocation. 

3283 """ 

3284 try: 

3285 func(*args, **kwargs) 

3286 

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

3288 if session: 

3289 session.rollback() 

3290 if onerror: 

3291 onerror() 

3292 else: 

3293 log.warning("failed to invoke thread callable: %s", func, exc_info=True) 

3294 if progress: 

3295 progress.handle_error(error) 

3296 

3297 else: 

3298 if session: 

3299 session.commit() 

3300 if progress: 

3301 progress.handle_success() 

3302 

3303 finally: 

3304 if session: 

3305 session.close() 

3306 

3307 ############################## 

3308 # merge methods 

3309 ############################## 

3310 

3311 def merge(self): 

3312 """ 

3313 View for merging two records. 

3314 

3315 By default, this view is included only if :attr:`mergeable` is 

3316 true. It usually maps to a URL like ``/widgets/merge``. 

3317 

3318 A POST request must be used for this view; otherwise it will 

3319 redirect to the :meth:`index()` view. The POST data must 

3320 specify a ``uuids`` param string in 

3321 ``"removing_uuid,keeping_uuid"`` format. 

3322 

3323 The user is first shown a "diff" with the 

3324 removing/keeping/final data records, as simple preview. They 

3325 can swap removing vs. keeping if needed, and when satisfied 

3326 they can "execute" the merge. 

3327 

3328 See also related methods, used by this one: 

3329 

3330 * :meth:`merge_validate_and_execute()` 

3331 * :meth:`merge_get_data()` 

3332 * :meth:`merge_get_final_data()` 

3333 """ 

3334 if self.request.method != "POST": 

3335 return self.redirect(self.get_index_url()) 

3336 

3337 session = self.Session() 

3338 model_class = self.get_model_class() 

3339 

3340 # load records to be kept/removed 

3341 removing = keeping = None 

3342 uuids = self.request.POST.get("uuids", "").split(",") 

3343 if len(uuids) == 2: 

3344 uuid1, uuid2 = uuids 

3345 try: 

3346 uuid1 = UUID(uuid1) 

3347 uuid2 = UUID(uuid2) 

3348 except ValueError: 

3349 pass 

3350 else: 

3351 removing = session.get(model_class, uuid1) 

3352 keeping = session.get(model_class, uuid2) 

3353 

3354 # redirect to listing if record(s) not found 

3355 if not (removing and keeping): 

3356 raise self.redirect(self.get_index_url()) 

3357 

3358 # maybe execute merge 

3359 if self.request.POST.get("execute-merge") == "true": 

3360 if self.merge_validate_and_execute(removing, keeping): 

3361 return self.redirect(self.get_action_url("view", keeping)) 

3362 

3363 removing_data = self.merge_get_data(removing) 

3364 keeping_data = self.merge_get_data(keeping) 

3365 diff = MergeDiff( 

3366 self.config, 

3367 removing_data, 

3368 keeping_data, 

3369 self.merge_get_final_data(removing_data, keeping_data), 

3370 fields=self.merge_get_all_fields(), 

3371 ) 

3372 

3373 context = {"removing": removing, "keeping": keeping, "diff": diff} 

3374 return self.render_to_response("merge", context) 

3375 

3376 def merge_get_simple_fields(self): 

3377 """ 

3378 Return the list of "simple" fields for the merge. 

3379 

3380 These "simple" fields will not have any special handling for 

3381 the merge. In other words the "removing" record values will 

3382 be ignored and the "keeping" record values will remain in 

3383 place, without modification. 

3384 

3385 If the view class defines :attr:`merge_simple_fields`, that 

3386 list is returned as-is. Otherwise the list of columns from 

3387 :attr:`model_class` is returned. 

3388 

3389 :returns: List of simple field names. 

3390 """ 

3391 if self.merge_simple_fields: 

3392 return list(self.merge_simple_fields) 

3393 

3394 mapper = sa.inspect(self.get_model_class()) 

3395 fields = mapper.columns.keys() 

3396 return fields 

3397 

3398 def merge_get_additive_fields(self): 

3399 """ 

3400 Return the list of "additive" fields for the merge. 

3401 

3402 Values from the removing/keeping record will be conceptually 

3403 added together, for each of these fields. 

3404 

3405 If the view class defines :attr:`merge_additive_fields`, that 

3406 list is returned as-is. Otherwise an empty list is returned. 

3407 

3408 :returns: List of additive field names. 

3409 """ 

3410 if self.merge_additive_fields: 

3411 return list(self.merge_additive_fields) 

3412 return [] 

3413 

3414 def merge_get_coalesce_fields(self): 

3415 """ 

3416 Return the list of "coalesce" fields for the merge. 

3417 

3418 Values from the removing/keeping record will be conceptually 

3419 "coalesced" for each of these fields. 

3420 

3421 If the view class defines :attr:`merge_coalesce_fields`, that 

3422 list is returned as-is. Otherwise an empty list is returned. 

3423 

3424 :returns: List of coalesce field names. 

3425 """ 

3426 if self.merge_coalesce_fields: 

3427 return list(self.merge_coalesce_fields) 

3428 return [] 

3429 

3430 def merge_get_all_fields(self): 

3431 """ 

3432 Return the list of *all* fields for the merge. 

3433 

3434 This will call each of the following methods to collect all 

3435 field names, then it returns the full *sorted* list. 

3436 

3437 * :meth:`merge_get_additive_fields()` 

3438 * :meth:`merge_get_coalesce_fields()` 

3439 * :meth:`merge_get_simple_fields()` 

3440 

3441 :returns: Sorted list of all field names. 

3442 """ 

3443 fields = set() 

3444 fields.update(self.merge_get_simple_fields()) 

3445 fields.update(self.merge_get_additive_fields()) 

3446 fields.update(self.merge_get_coalesce_fields()) 

3447 return sorted(fields) 

3448 

3449 def merge_get_data(self, obj): 

3450 """ 

3451 Return a data dict for the given object, which will be either 

3452 the "removing" or "keeping" record for the merge. 

3453 

3454 By default this calls :meth:`merge_get_all_fields()` and then 

3455 for each field, calls ``getattr()`` on the object. Subclass 

3456 can override as needed for custom logic. 

3457 

3458 :param obj: Reference to model/record instance. 

3459 

3460 :returns: Data dict with all field values. 

3461 """ 

3462 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()} 

3463 

3464 def merge_get_final_data(self, removing, keeping): 

3465 """ 

3466 Return the "final" data dict for the merge. 

3467 

3468 The result will be identical to the "keeping" record, for all 

3469 "simple" fields. However the "additive" and "coalesce" fields 

3470 are handled specially per their nature, in which case those 

3471 final values may or may not match the "keeping" record. 

3472 

3473 :param removing: Data dict for the "removing" record. 

3474 

3475 :param keeping: Data dict for the "keeping" record. 

3476 

3477 :returns: Data dict with all "final" field values. 

3478 

3479 See also: 

3480 

3481 * :meth:`merge()` 

3482 * :meth:`merge_get_additive_fields()` 

3483 * :meth:`merge_get_coalesce_fields()` 

3484 """ 

3485 final = dict(keeping) 

3486 

3487 for field in self.merge_get_additive_fields(): 

3488 if isinstance(keeping[field], list): 

3489 final[field] = sorted(set(removing[field] + keeping[field])) 

3490 else: 

3491 final[field] = removing[field] + keeping[field] 

3492 

3493 for field in self.merge_get_coalesce_fields(): 

3494 if removing[field] is not None and keeping[field] is None: 

3495 final[field] = removing[field] 

3496 elif removing[field] and not keeping[field]: 

3497 final[field] = removing[field] 

3498 

3499 return final 

3500 

3501 def merge_validate_and_execute(self, removing, keeping): 

3502 """ 

3503 Validate and execute a merge for the two given records. It is 

3504 called from :meth:`merge()`. 

3505 

3506 This calls :meth:`merge_why_not()` and if that does not yield 

3507 a reason to prevent the merge, then calls 

3508 :meth:`merge_execute()`. 

3509 

3510 If there was a reason not to merge, or if an error occurs 

3511 during merge execution, a flash warning/error message is set 

3512 to notify the user what happened. 

3513 

3514 :param removing: Reference to the "removing" model instance/record. 

3515 

3516 :param keeping: Reference to the "keeping" model instance/record. 

3517 

3518 :returns: Boolean indicating whether merge execution completed 

3519 successfully. 

3520 """ 

3521 session = self.Session() 

3522 

3523 # validate the merge 

3524 if reason := self.merge_why_not(removing, keeping): 

3525 warning = HTML.tag( 

3526 "p", class_="block", c="Merge cannot proceed:" 

3527 ) + HTML.tag("p", class_="block", c=reason) 

3528 self.request.session.flash(warning, "warning") 

3529 return False 

3530 

3531 # execute the merge 

3532 removed_title = str(removing) 

3533 try: 

3534 self.merge_execute(removing, keeping) 

3535 session.flush() 

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

3537 session.rollback() 

3538 log.warning("merge failed", exc_info=True) 

3539 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag( 

3540 "p", class_="block", c=self.app.render_error(err) 

3541 ) 

3542 self.request.session.flash(warning, "error") 

3543 return False 

3544 

3545 self.request.session.flash(f"{removed_title} has been merged into {keeping}") 

3546 return True 

3547 

3548 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument 

3549 """ 

3550 Can return a "reason" why the two given records should not be merged. 

3551 

3552 This returns ``None`` by default, indicating the merge is 

3553 allowed. Subclass can override as needed for custom logic. 

3554 

3555 See also :meth:`merge_validate_and_execute()`. 

3556 

3557 :param removing: Reference to the "removing" model instance/record. 

3558 

3559 :param keeping: Reference to the "keeping" model instance/record. 

3560 

3561 :returns: Reason not to merge (as string), or ``None``. 

3562 """ 

3563 return None 

3564 

3565 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument 

3566 """ 

3567 Execute the actual merge for the two given objects. 

3568 

3569 Default logic simply deletes the "removing" record. Subclass 

3570 can override as needed for custom logic. 

3571 

3572 See also :meth:`merge_validate_and_execute()`. 

3573 

3574 :param removing: Reference to the "removing" model instance/record. 

3575 

3576 :param keeping: Reference to the "keeping" model instance/record. 

3577 """ 

3578 session = self.Session() 

3579 

3580 # nb. default "merge" does not update kept object! 

3581 session.delete(removing) 

3582 

3583 ############################## 

3584 # row methods 

3585 ############################## 

3586 

3587 def get_rows_title(self): 

3588 """ 

3589 Returns the display title for model **rows** grid, if 

3590 applicable/desired. Only relevant if :attr:`has_rows` is 

3591 true. 

3592 

3593 There is no default here, but subclass may override by 

3594 assigning :attr:`rows_title`. 

3595 """ 

3596 if hasattr(self, "rows_title"): 

3597 return self.rows_title 

3598 return self.get_row_model_title_plural() 

3599 

3600 def get_row_parent(self, row): 

3601 """ 

3602 This must return the parent object for the given child row. 

3603 Only relevant if :attr:`has_rows` is true. 

3604 

3605 Default logic is not implemented; subclass must override. 

3606 """ 

3607 raise NotImplementedError 

3608 

3609 def make_row_model_grid(self, obj, **kwargs): 

3610 """ 

3611 Create and return a grid for a record's **rows** data, for use 

3612 in :meth:`view()`. Only applicable if :attr:`has_rows` is 

3613 true. 

3614 

3615 :param obj: Current model instance for which rows data is 

3616 being displayed. 

3617 

3618 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the 

3619 rows data. 

3620 

3621 See also related methods, which are called by this one: 

3622 

3623 * :meth:`get_row_grid_key()` 

3624 * :meth:`get_row_grid_columns()` 

3625 * :meth:`get_row_grid_data()` 

3626 * :meth:`configure_row_grid()` 

3627 """ 

3628 if "key" not in kwargs: 

3629 kwargs["key"] = self.get_row_grid_key() 

3630 

3631 if "model_class" not in kwargs: 

3632 model_class = self.get_row_model_class() 

3633 if model_class: 

3634 kwargs["model_class"] = model_class 

3635 

3636 if "columns" not in kwargs: 

3637 kwargs["columns"] = self.get_row_grid_columns() 

3638 

3639 if "data" not in kwargs: 

3640 kwargs["data"] = self.get_row_grid_data(obj) 

3641 

3642 kwargs.setdefault("filterable", self.rows_filterable) 

3643 kwargs.setdefault("filter_defaults", self.rows_filter_defaults) 

3644 kwargs.setdefault("sortable", self.rows_sortable) 

3645 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend) 

3646 kwargs.setdefault("sort_defaults", self.rows_sort_defaults) 

3647 kwargs.setdefault("paginated", self.rows_paginated) 

3648 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend) 

3649 

3650 if "actions" not in kwargs: 

3651 actions = [] 

3652 

3653 if self.rows_viewable: 

3654 actions.append( 

3655 self.make_grid_action( 

3656 "view", icon="eye", url=self.get_row_action_url_view 

3657 ) 

3658 ) 

3659 

3660 if actions: 

3661 kwargs["actions"] = actions 

3662 

3663 grid = self.make_grid(**kwargs) 

3664 self.configure_row_grid(grid) 

3665 grid.load_settings() 

3666 return grid 

3667 

3668 def get_row_grid_key(self): 

3669 """ 

3670 Returns the (presumably) unique key to be used for the 

3671 **rows** grid in :meth:`view()`. Only relevant if 

3672 :attr:`has_rows` is true. 

3673 

3674 This is called from :meth:`make_row_model_grid()`; in the 

3675 resulting grid, this becomes 

3676 :attr:`~wuttaweb.grids.base.Grid.key`. 

3677 

3678 Whereas you can define :attr:`grid_key` for the main grid, the 

3679 row grid key is always generated dynamically. This 

3680 incorporates the current record key (whose rows are in the 

3681 grid) so that the rows grid for each record is unique. 

3682 """ 

3683 parts = [self.get_grid_key()] 

3684 for key in self.get_model_key(): 

3685 parts.append(str(self.request.matchdict[key])) 

3686 return ".".join(parts) 

3687 

3688 def get_row_grid_columns(self): 

3689 """ 

3690 Returns the default list of column names for the **rows** 

3691 grid, for use in :meth:`view()`. Only relevant if 

3692 :attr:`has_rows` is true. 

3693 

3694 This is called by :meth:`make_row_model_grid()`; in the 

3695 resulting grid, this becomes 

3696 :attr:`~wuttaweb.grids.base.Grid.columns`. 

3697 

3698 This method may return ``None``, in which case the grid may 

3699 (try to) generate its own default list. 

3700 

3701 Subclass may define :attr:`row_grid_columns` for simple cases, 

3702 or can override this method if needed. 

3703 

3704 Also note that :meth:`configure_row_grid()` may be used to 

3705 further modify the final column set, regardless of what this 

3706 method returns. So a common pattern is to declare all 

3707 "supported" columns by setting :attr:`row_grid_columns` but 

3708 then optionally remove or replace some of those within 

3709 :meth:`configure_row_grid()`. 

3710 """ 

3711 if hasattr(self, "row_grid_columns"): 

3712 return self.row_grid_columns 

3713 return None 

3714 

3715 def get_row_grid_data(self, obj): 

3716 """ 

3717 Returns the data for the **rows** grid, for use in 

3718 :meth:`view()`. Only relevant if :attr:`has_rows` is true. 

3719 

3720 This is called by :meth:`make_row_model_grid()`; in the 

3721 resulting grid, this becomes 

3722 :attr:`~wuttaweb.grids.base.Grid.data`. 

3723 

3724 Default logic not implemented; subclass must define this. 

3725 """ 

3726 raise NotImplementedError 

3727 

3728 def configure_row_grid(self, grid): 

3729 """ 

3730 Configure the **rows** grid for use in :meth:`view()`. Only 

3731 relevant if :attr:`has_rows` is true. 

3732 

3733 This is called by :meth:`make_row_model_grid()`. 

3734 

3735 There is minimal default logic here; subclass should override 

3736 as needed. The ``grid`` param will already be "complete" and 

3737 ready to use as-is, but this method can further modify it 

3738 based on request details etc. 

3739 """ 

3740 grid.remove("uuid") 

3741 self.set_row_labels(grid) 

3742 

3743 def set_row_labels(self, obj): 

3744 """ 

3745 Set label overrides on a **row** form or grid, based on what 

3746 is defined by the view class and its parent class(es). 

3747 

3748 This is called automatically from 

3749 :meth:`configure_row_grid()` and 

3750 :meth:`configure_row_form()`. 

3751 

3752 This calls :meth:`collect_row_labels()` to find everything, 

3753 then it assigns the labels using one of (based on ``obj`` 

3754 type): 

3755 

3756 * :func:`wuttaweb.forms.base.Form.set_label()` 

3757 * :func:`wuttaweb.grids.base.Grid.set_label()` 

3758 

3759 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a 

3760 :class:`~wuttaweb.forms.base.Form` instance. 

3761 """ 

3762 labels = self.collect_row_labels() 

3763 for key, label in labels.items(): 

3764 obj.set_label(key, label) 

3765 

3766 def collect_row_labels(self): 

3767 """ 

3768 Collect all **row** labels defined within the view class 

3769 hierarchy. 

3770 

3771 This is called by :meth:`set_row_labels()`. 

3772 

3773 :returns: Dict of all labels found. 

3774 """ 

3775 labels = {} 

3776 hierarchy = self.get_class_hierarchy() 

3777 for cls in hierarchy: 

3778 if hasattr(cls, "row_labels"): 

3779 labels.update(cls.row_labels) 

3780 return labels 

3781 

3782 def get_row_action_url_view(self, row, i): 

3783 """ 

3784 Must return the "view" action url for the given row object. 

3785 

3786 Only relevant if :attr:`rows_viewable` is true. 

3787 

3788 There is no default logic; subclass must override if needed. 

3789 """ 

3790 raise NotImplementedError 

3791 

3792 def create_row(self): 

3793 """ 

3794 View to create a new "child row" record. 

3795 

3796 This usually corresponds to a URL like ``/widgets/XXX/new-row``. 

3797 

3798 By default, this view is included only if 

3799 :attr:`rows_creatable` is true. 

3800 

3801 The default "create row" view logic will show a form with 

3802 field widgets, allowing user to submit new values which are 

3803 then persisted to the DB (assuming typical SQLAlchemy model). 

3804 

3805 Subclass normally should not override this method, but rather 

3806 one of the related methods which are called (in)directly by 

3807 this one: 

3808 

3809 * :meth:`make_row_model_form()` 

3810 * :meth:`configure_row_form()` 

3811 * :meth:`create_row_save_form()` 

3812 * :meth:`redirect_after_create_row()` 

3813 """ 

3814 self.creating = True 

3815 parent = self.get_instance() 

3816 parent_url = self.get_action_url("view", parent) 

3817 

3818 form = self.make_row_model_form(cancel_url_fallback=parent_url) 

3819 if form.validate(): 

3820 result = self.create_row_save_form(form) 

3821 return self.redirect_after_create_row(result) 

3822 

3823 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

3824 parent_link = tags.link_to(self.get_instance_title(parent), parent_url) 

3825 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

3826 [index_link, parent_link] 

3827 ) 

3828 

3829 context = { 

3830 "form": form, 

3831 "index_title_rendered": index_title_rendered, 

3832 "row_model_title": self.get_row_model_title(), 

3833 } 

3834 return self.render_to_response("create_row", context) 

3835 

3836 def create_row_save_form(self, form): 

3837 """ 

3838 This method converts the validated form data to a row model 

3839 instance, and then saves the result to DB. It is called by 

3840 :meth:`create_row()`. 

3841 

3842 :returns: The resulting row model instance, as produced by 

3843 :meth:`objectify()`. 

3844 """ 

3845 row = self.objectify(form) 

3846 session = self.Session() 

3847 session.add(row) 

3848 session.flush() 

3849 return row 

3850 

3851 def redirect_after_create_row(self, row): 

3852 """ 

3853 Returns a redirect to the "view parent" page relative to the 

3854 given newly-created row. Subclass may override as needed. 

3855 

3856 This is called by :meth:`create_row()`. 

3857 """ 

3858 parent = self.get_row_parent(row) 

3859 return self.redirect(self.get_action_url("view", parent)) 

3860 

3861 def make_row_model_form(self, model_instance=None, **kwargs): 

3862 """ 

3863 Create and return a form for the row model. 

3864 

3865 This is called by :meth:`create_row()`. 

3866 

3867 See also related methods, which are called by this one: 

3868 

3869 * :meth:`get_row_model_class()` 

3870 * :meth:`get_row_form_fields()` 

3871 * :meth:`~wuttaweb.views.base.View.make_form()` 

3872 * :meth:`configure_row_form()` 

3873 

3874 :returns: :class:`~wuttaweb.forms.base.Form` instance 

3875 """ 

3876 if "model_class" not in kwargs: 

3877 model_class = self.get_row_model_class() 

3878 if model_class: 

3879 kwargs["model_class"] = model_class 

3880 

3881 kwargs["model_instance"] = model_instance 

3882 

3883 if not kwargs.get("fields"): 

3884 fields = self.get_row_form_fields() 

3885 if fields: 

3886 kwargs["fields"] = fields 

3887 

3888 form = self.make_form(**kwargs) 

3889 self.configure_row_form(form) 

3890 return form 

3891 

3892 def get_row_form_fields(self): 

3893 """ 

3894 Returns the initial list of field names for the row model 

3895 form. 

3896 

3897 This is called by :meth:`make_row_model_form()`; in the 

3898 resulting :class:`~wuttaweb.forms.base.Form` instance, this 

3899 becomes :attr:`~wuttaweb.forms.base.Form.fields`. 

3900 

3901 This method may return ``None``, in which case the form may 

3902 (try to) generate its own default list. 

3903 

3904 Subclass may define :attr:`row_form_fields` for simple cases, 

3905 or can override this method if needed. 

3906 

3907 Note that :meth:`configure_row_form()` may be used to further 

3908 modify the final field list, regardless of what this method 

3909 returns. So a common pattern is to declare all "supported" 

3910 fields by setting :attr:`row_form_fields` but then optionally 

3911 remove or replace some in :meth:`configure_row_form()`. 

3912 """ 

3913 if hasattr(self, "row_form_fields"): 

3914 return self.row_form_fields 

3915 return None 

3916 

3917 def configure_row_form(self, form): 

3918 """ 

3919 Configure the row model form. 

3920 

3921 This is called by :meth:`make_row_model_form()` - for multiple 

3922 CRUD views (create, view, edit, delete, possibly others). 

3923 

3924 The ``form`` param will already be "complete" and ready to use 

3925 as-is, but this method can further modify it based on request 

3926 details etc. 

3927 

3928 Subclass can override as needed, although be sure to invoke 

3929 this parent method via ``super()`` if so. 

3930 """ 

3931 form.remove("uuid") 

3932 self.set_row_labels(form) 

3933 

3934 ############################## 

3935 # class methods 

3936 ############################## 

3937 

3938 @classmethod 

3939 def get_model_class(cls): 

3940 """ 

3941 Returns the model class for the view (if defined). 

3942 

3943 A model class will *usually* be a SQLAlchemy mapped class, 

3944 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 

3945 

3946 There is no default value here, but a subclass may override by 

3947 assigning :attr:`model_class`. 

3948 

3949 Note that the model class is not *required* - however if you 

3950 do not set the :attr:`model_class`, then you *must* set the 

3951 :attr:`model_name`. 

3952 """ 

3953 return cls.model_class 

3954 

3955 @classmethod 

3956 def get_model_name(cls): 

3957 """ 

3958 Returns the model name for the view. 

3959 

3960 A model name should generally be in the format of a Python 

3961 class name, e.g. ``'WuttaWidget'``. (Note this is 

3962 *singular*, not plural.) 

3963 

3964 The default logic will call :meth:`get_model_class()` and 

3965 return that class name as-is. A subclass may override by 

3966 assigning :attr:`model_name`. 

3967 """ 

3968 if hasattr(cls, "model_name"): 

3969 return cls.model_name 

3970 

3971 return cls.get_model_class().__name__ 

3972 

3973 @classmethod 

3974 def get_model_name_normalized(cls): 

3975 """ 

3976 Returns the "normalized" model name for the view. 

3977 

3978 A normalized model name should generally be in the format of a 

3979 Python variable name, e.g. ``'wutta_widget'``. (Note this is 

3980 *singular*, not plural.) 

3981 

3982 The default logic will call :meth:`get_model_name()` and 

3983 simply lower-case the result. A subclass may override by 

3984 assigning :attr:`model_name_normalized`. 

3985 """ 

3986 if hasattr(cls, "model_name_normalized"): 

3987 return cls.model_name_normalized 

3988 

3989 return cls.get_model_name().lower() 

3990 

3991 @classmethod 

3992 def get_model_title(cls): 

3993 """ 

3994 Returns the "humanized" (singular) model title for the view. 

3995 

3996 The model title will be displayed to the user, so should have 

3997 proper grammar and capitalization, e.g. ``"Wutta Widget"``. 

3998 (Note this is *singular*, not plural.) 

3999 

4000 The default logic will call :meth:`get_model_name()` and use 

4001 the result as-is. A subclass may override by assigning 

4002 :attr:`model_title`. 

4003 """ 

4004 if hasattr(cls, "model_title"): 

4005 return cls.model_title 

4006 

4007 if model_class := cls.get_model_class(): 

4008 if hasattr(model_class, "__wutta_hint__"): 

4009 if model_title := model_class.__wutta_hint__.get("model_title"): 

4010 return model_title 

4011 

4012 return cls.get_model_name() 

4013 

4014 @classmethod 

4015 def get_model_title_plural(cls): 

4016 """ 

4017 Returns the "humanized" (plural) model title for the view. 

4018 

4019 The model title will be displayed to the user, so should have 

4020 proper grammar and capitalization, e.g. ``"Wutta Widgets"``. 

4021 (Note this is *plural*, not singular.) 

4022 

4023 The default logic will call :meth:`get_model_title()` and 

4024 simply add a ``'s'`` to the end. A subclass may override by 

4025 assigning :attr:`model_title_plural`. 

4026 """ 

4027 if hasattr(cls, "model_title_plural"): 

4028 return cls.model_title_plural 

4029 

4030 if model_class := cls.get_model_class(): 

4031 if hasattr(model_class, "__wutta_hint__"): 

4032 if model_title_plural := model_class.__wutta_hint__.get( 

4033 "model_title_plural" 

4034 ): 

4035 return model_title_plural 

4036 

4037 model_title = cls.get_model_title() 

4038 return f"{model_title}s" 

4039 

4040 @classmethod 

4041 def get_model_key(cls): 

4042 """ 

4043 Returns the "model key" for the master view. 

4044 

4045 This should return a tuple containing one or more "field 

4046 names" corresponding to the primary key for data records. 

4047 

4048 In the most simple/common scenario, where the master view 

4049 represents a Wutta-based SQLAlchemy model, the return value 

4050 for this method is: ``('uuid',)`` 

4051 

4052 Any class mapped via SQLAlchemy should be supported 

4053 automatically, the keys are determined from class inspection. 

4054 

4055 But there is no "sane" default for other scenarios, in which 

4056 case subclass should define :attr:`model_key`. If the model 

4057 key cannot be determined, raises ``AttributeError``. 

4058 

4059 :returns: Tuple of field names comprising the model key. 

4060 """ 

4061 if hasattr(cls, "model_key"): 

4062 keys = cls.model_key 

4063 if isinstance(keys, str): 

4064 keys = [keys] 

4065 return tuple(keys) 

4066 

4067 model_class = cls.get_model_class() 

4068 if model_class: 

4069 # nb. we want the primary key but must avoid column names 

4070 # in case mapped class uses different prop keys 

4071 inspector = sa.inspect(model_class) 

4072 keys = [col.name for col in inspector.primary_key] 

4073 return tuple( 

4074 prop.key 

4075 for prop in inspector.column_attrs 

4076 if all(col.name in keys for col in prop.columns) 

4077 ) 

4078 

4079 raise AttributeError(f"you must define model_key for view class: {cls}") 

4080 

4081 @classmethod 

4082 def get_route_prefix(cls): 

4083 """ 

4084 Returns the "route prefix" for the master view. This prefix 

4085 is used for all named routes defined by the view class. 

4086 

4087 For instance if route prefix is ``'widgets'`` then a view 

4088 might have these routes: 

4089 

4090 * ``'widgets'`` 

4091 * ``'widgets.create'`` 

4092 * ``'widgets.edit'`` 

4093 * ``'widgets.delete'`` 

4094 

4095 The default logic will call 

4096 :meth:`get_model_name_normalized()` and simply add an ``'s'`` 

4097 to the end, making it plural. A subclass may override by 

4098 assigning :attr:`route_prefix`. 

4099 """ 

4100 if hasattr(cls, "route_prefix"): 

4101 return cls.route_prefix 

4102 

4103 model_name = cls.get_model_name_normalized() 

4104 return f"{model_name}s" 

4105 

4106 @classmethod 

4107 def get_permission_prefix(cls): 

4108 """ 

4109 Returns the "permission prefix" for the master view. This 

4110 prefix is used for all permissions defined by the view class. 

4111 

4112 For instance if permission prefix is ``'widgets'`` then a view 

4113 might have these permissions: 

4114 

4115 * ``'widgets.list'`` 

4116 * ``'widgets.create'`` 

4117 * ``'widgets.edit'`` 

4118 * ``'widgets.delete'`` 

4119 

4120 The default logic will call :meth:`get_route_prefix()` and use 

4121 that value as-is. A subclass may override by assigning 

4122 :attr:`permission_prefix`. 

4123 """ 

4124 if hasattr(cls, "permission_prefix"): 

4125 return cls.permission_prefix 

4126 

4127 return cls.get_route_prefix() 

4128 

4129 @classmethod 

4130 def get_url_prefix(cls): 

4131 """ 

4132 Returns the "URL prefix" for the master view. This prefix is 

4133 used for all URLs defined by the view class. 

4134 

4135 Using the same example as in :meth:`get_route_prefix()`, the 

4136 URL prefix would be ``'/widgets'`` and the view would have 

4137 defined routes for these URLs: 

4138 

4139 * ``/widgets/`` 

4140 * ``/widgets/new`` 

4141 * ``/widgets/XXX/edit`` 

4142 * ``/widgets/XXX/delete`` 

4143 

4144 The default logic will call :meth:`get_route_prefix()` and 

4145 simply add a ``'/'`` to the beginning. A subclass may 

4146 override by assigning :attr:`url_prefix`. 

4147 """ 

4148 if hasattr(cls, "url_prefix"): 

4149 return cls.url_prefix 

4150 

4151 route_prefix = cls.get_route_prefix() 

4152 return f"/{route_prefix}" 

4153 

4154 @classmethod 

4155 def get_instance_url_prefix(cls): 

4156 """ 

4157 Generate the URL prefix specific to an instance for this model 

4158 view. This will include model key param placeholders; it 

4159 winds up looking like: 

4160 

4161 * ``/widgets/{uuid}`` 

4162 * ``/resources/{foo}|{bar}|{baz}`` 

4163 

4164 The former being the most simple/common, and the latter 

4165 showing what a "composite" model key looks like, with pipe 

4166 symbols separating the key parts. 

4167 """ 

4168 prefix = cls.get_url_prefix() + "/" 

4169 for i, key in enumerate(cls.get_model_key()): 

4170 if i: 

4171 prefix += "|" 

4172 prefix += f"{{{key}}}" 

4173 return prefix 

4174 

4175 @classmethod 

4176 def get_template_prefix(cls): 

4177 """ 

4178 Returns the "template prefix" for the master view. This 

4179 prefix is used to guess which template path to render for a 

4180 given view. 

4181 

4182 Using the same example as in :meth:`get_url_prefix()`, the 

4183 template prefix would also be ``'/widgets'`` and the templates 

4184 assumed for those routes would be: 

4185 

4186 * ``/widgets/index.mako`` 

4187 * ``/widgets/create.mako`` 

4188 * ``/widgets/edit.mako`` 

4189 * ``/widgets/delete.mako`` 

4190 

4191 The default logic will call :meth:`get_url_prefix()` and 

4192 return that value as-is. A subclass may override by assigning 

4193 :attr:`template_prefix`. 

4194 """ 

4195 if hasattr(cls, "template_prefix"): 

4196 return cls.template_prefix 

4197 

4198 return cls.get_url_prefix() 

4199 

4200 @classmethod 

4201 def get_grid_key(cls): 

4202 """ 

4203 Returns the (presumably) unique key to be used for the primary 

4204 grid in the :meth:`index()` view. This key may also be used 

4205 as the basis (key prefix) for secondary grids. 

4206 

4207 This is called from :meth:`make_model_grid()`; in the 

4208 resulting :class:`~wuttaweb.grids.base.Grid` instance, this 

4209 becomes :attr:`~wuttaweb.grids.base.Grid.key`. 

4210 

4211 The default logic for this method will call 

4212 :meth:`get_route_prefix()` and return that value as-is. A 

4213 subclass may override by assigning :attr:`grid_key`. 

4214 """ 

4215 if hasattr(cls, "grid_key"): 

4216 return cls.grid_key 

4217 

4218 return cls.get_route_prefix() 

4219 

4220 @classmethod 

4221 def get_config_title(cls): 

4222 """ 

4223 Returns the "config title" for the view/model. 

4224 

4225 The config title is used for page title in the 

4226 :meth:`configure()` view, as well as links to it. It is 

4227 usually plural, e.g. ``"Wutta Widgets"`` in which case that 

4228 winds up being displayed in the web app as: **Configure Wutta 

4229 Widgets** 

4230 

4231 The default logic will call :meth:`get_model_title_plural()` 

4232 and return that as-is. A subclass may override by assigning 

4233 :attr:`config_title`. 

4234 """ 

4235 if hasattr(cls, "config_title"): 

4236 return cls.config_title 

4237 

4238 return cls.get_model_title_plural() 

4239 

4240 @classmethod 

4241 def get_row_model_class(cls): 

4242 """ 

4243 Returns the "child row" model class for the view. Only 

4244 relevant if :attr:`has_rows` is true. 

4245 

4246 Default logic returns the :attr:`row_model_class` reference. 

4247 

4248 :returns: Mapped class, or ``None`` 

4249 """ 

4250 return cls.row_model_class 

4251 

4252 @classmethod 

4253 def get_row_model_name(cls): 

4254 """ 

4255 Returns the row model name for the view. 

4256 

4257 A model name should generally be in the format of a Python 

4258 class name, e.g. ``'BatchRow'``. (Note this is *singular*, 

4259 not plural.) 

4260 

4261 The default logic will call :meth:`get_row_model_class()` and 

4262 return that class name as-is. Subclass may override by 

4263 assigning :attr:`row_model_name`. 

4264 """ 

4265 if hasattr(cls, "row_model_name"): 

4266 return cls.row_model_name 

4267 

4268 return cls.get_row_model_class().__name__ 

4269 

4270 @classmethod 

4271 def get_row_model_title(cls): 

4272 """ 

4273 Returns the "humanized" (singular) title for the row model. 

4274 

4275 The model title will be displayed to the user, so should have 

4276 proper grammar and capitalization, e.g. ``"Batch Row"``. 

4277 (Note this is *singular*, not plural.) 

4278 

4279 The default logic will call :meth:`get_row_model_name()` and 

4280 use the result as-is. Subclass may override by assigning 

4281 :attr:`row_model_title`. 

4282 

4283 See also :meth:`get_row_model_title_plural()`. 

4284 """ 

4285 if hasattr(cls, "row_model_title"): 

4286 return cls.row_model_title 

4287 

4288 if model_class := cls.get_row_model_class(): 

4289 if hasattr(model_class, "__wutta_hint__"): 

4290 if model_title := model_class.__wutta_hint__.get("model_title"): 

4291 return model_title 

4292 

4293 return cls.get_row_model_name() 

4294 

4295 @classmethod 

4296 def get_row_model_title_plural(cls): 

4297 """ 

4298 Returns the "humanized" (plural) title for the row model. 

4299 

4300 The model title will be displayed to the user, so should have 

4301 proper grammar and capitalization, e.g. ``"Batch Rows"``. 

4302 (Note this is *plural*, not singular.) 

4303 

4304 The default logic will call :meth:`get_row_model_title()` and 

4305 simply add a ``'s'`` to the end. Subclass may override by 

4306 assigning :attr:`row_model_title_plural`. 

4307 """ 

4308 if hasattr(cls, "row_model_title_plural"): 

4309 return cls.row_model_title_plural 

4310 

4311 if model_class := cls.get_row_model_class(): 

4312 if hasattr(model_class, "__wutta_hint__"): 

4313 if model_title_plural := model_class.__wutta_hint__.get( 

4314 "model_title_plural" 

4315 ): 

4316 return model_title_plural 

4317 

4318 row_model_title = cls.get_row_model_title() 

4319 return f"{row_model_title}s" 

4320 

4321 ############################## 

4322 # configuration 

4323 ############################## 

4324 

4325 @classmethod 

4326 def defaults(cls, config): 

4327 """ 

4328 Provide default Pyramid configuration for a master view. 

4329 

4330 This is generally called from within the module's 

4331 ``includeme()`` function, e.g.:: 

4332 

4333 from wuttaweb.views import MasterView 

4334 

4335 class WidgetView(MasterView): 

4336 model_name = 'Widget' 

4337 

4338 def includeme(config): 

4339 WidgetView.defaults(config) 

4340 

4341 :param config: Reference to the app's 

4342 :class:`pyramid:pyramid.config.Configurator` instance. 

4343 """ 

4344 cls._defaults(config) 

4345 

4346 @classmethod 

4347 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches 

4348 wutta_config = config.registry.settings.get("wutta_config") 

4349 app = wutta_config.get_app() 

4350 

4351 route_prefix = cls.get_route_prefix() 

4352 permission_prefix = cls.get_permission_prefix() 

4353 url_prefix = cls.get_url_prefix() 

4354 model_title = cls.get_model_title() 

4355 model_title_plural = cls.get_model_title_plural() 

4356 

4357 # add to master view registry 

4358 config.add_wutta_master_view(cls) 

4359 

4360 # permission group 

4361 config.add_wutta_permission_group( 

4362 permission_prefix, model_title_plural, overwrite=False 

4363 ) 

4364 

4365 # index 

4366 if cls.listable: 

4367 config.add_route(route_prefix, f"{url_prefix}/") 

4368 config.add_view( 

4369 cls, 

4370 attr="index", 

4371 route_name=route_prefix, 

4372 permission=f"{permission_prefix}.list", 

4373 ) 

4374 config.add_wutta_permission( 

4375 permission_prefix, 

4376 f"{permission_prefix}.list", 

4377 f"Browse / search {model_title_plural}", 

4378 ) 

4379 

4380 # grid totals 

4381 if cls.has_grid_totals: 

4382 config.add_route( 

4383 f"{route_prefix}.fetch_grid_totals", 

4384 f"{url_prefix}/fetch-grid-totals", 

4385 request_method="GET", 

4386 ) 

4387 config.add_view( 

4388 cls, 

4389 attr="fetch_grid_totals", 

4390 route_name=f"{route_prefix}.fetch_grid_totals", 

4391 permission=f"{permission_prefix}.list", 

4392 renderer="json", 

4393 ) 

4394 

4395 # create 

4396 if cls.creatable: 

4397 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new") 

4398 config.add_view( 

4399 cls, 

4400 attr="create", 

4401 route_name=f"{route_prefix}.create", 

4402 permission=f"{permission_prefix}.create", 

4403 ) 

4404 config.add_wutta_permission( 

4405 permission_prefix, 

4406 f"{permission_prefix}.create", 

4407 f"Create new {model_title}", 

4408 ) 

4409 

4410 # edit 

4411 if cls.editable: 

4412 instance_url_prefix = cls.get_instance_url_prefix() 

4413 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit") 

4414 config.add_view( 

4415 cls, 

4416 attr="edit", 

4417 route_name=f"{route_prefix}.edit", 

4418 permission=f"{permission_prefix}.edit", 

4419 ) 

4420 config.add_wutta_permission( 

4421 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}" 

4422 ) 

4423 

4424 # delete 

4425 if cls.deletable: 

4426 instance_url_prefix = cls.get_instance_url_prefix() 

4427 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete") 

4428 config.add_view( 

4429 cls, 

4430 attr="delete", 

4431 route_name=f"{route_prefix}.delete", 

4432 permission=f"{permission_prefix}.delete", 

4433 ) 

4434 config.add_wutta_permission( 

4435 permission_prefix, 

4436 f"{permission_prefix}.delete", 

4437 f"Delete {model_title}", 

4438 ) 

4439 

4440 # bulk delete 

4441 if cls.deletable_bulk: 

4442 config.add_route( 

4443 f"{route_prefix}.delete_bulk", 

4444 f"{url_prefix}/delete-bulk", 

4445 request_method="POST", 

4446 ) 

4447 config.add_view( 

4448 cls, 

4449 attr="delete_bulk", 

4450 route_name=f"{route_prefix}.delete_bulk", 

4451 permission=f"{permission_prefix}.delete_bulk", 

4452 ) 

4453 config.add_wutta_permission( 

4454 permission_prefix, 

4455 f"{permission_prefix}.delete_bulk", 

4456 f"Delete {model_title_plural} in bulk", 

4457 ) 

4458 

4459 # merge 

4460 if cls.mergeable: 

4461 config.add_wutta_permission( 

4462 permission_prefix, 

4463 f"{permission_prefix}.merge", 

4464 f"Merge 2 {model_title_plural}", 

4465 ) 

4466 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge") 

4467 config.add_view( 

4468 cls, 

4469 attr="merge", 

4470 route_name=f"{route_prefix}.merge", 

4471 permission=f"{permission_prefix}.merge", 

4472 ) 

4473 

4474 # autocomplete 

4475 if cls.has_autocomplete: 

4476 config.add_route( 

4477 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete" 

4478 ) 

4479 config.add_view( 

4480 cls, 

4481 attr="autocomplete", 

4482 route_name=f"{route_prefix}.autocomplete", 

4483 renderer="json", 

4484 permission=f"{route_prefix}.list", 

4485 ) 

4486 

4487 # download 

4488 if cls.downloadable: 

4489 instance_url_prefix = cls.get_instance_url_prefix() 

4490 config.add_route( 

4491 f"{route_prefix}.download", f"{instance_url_prefix}/download" 

4492 ) 

4493 config.add_view( 

4494 cls, 

4495 attr="download", 

4496 route_name=f"{route_prefix}.download", 

4497 permission=f"{permission_prefix}.download", 

4498 ) 

4499 config.add_wutta_permission( 

4500 permission_prefix, 

4501 f"{permission_prefix}.download", 

4502 f"Download file(s) for {model_title}", 

4503 ) 

4504 

4505 # execute 

4506 if cls.executable: 

4507 instance_url_prefix = cls.get_instance_url_prefix() 

4508 config.add_route( 

4509 f"{route_prefix}.execute", 

4510 f"{instance_url_prefix}/execute", 

4511 request_method="POST", 

4512 ) 

4513 config.add_view( 

4514 cls, 

4515 attr="execute", 

4516 route_name=f"{route_prefix}.execute", 

4517 permission=f"{permission_prefix}.execute", 

4518 ) 

4519 config.add_wutta_permission( 

4520 permission_prefix, 

4521 f"{permission_prefix}.execute", 

4522 f"Execute {model_title}", 

4523 ) 

4524 

4525 # configure 

4526 if cls.configurable: 

4527 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure") 

4528 config.add_view( 

4529 cls, 

4530 attr="configure", 

4531 route_name=f"{route_prefix}.configure", 

4532 permission=f"{permission_prefix}.configure", 

4533 ) 

4534 config.add_wutta_permission( 

4535 permission_prefix, 

4536 f"{permission_prefix}.configure", 

4537 f"Configure {model_title_plural}", 

4538 ) 

4539 

4540 # view 

4541 # nb. always register this one last, so it does not take 

4542 # priority over model-wide action routes, e.g. delete_bulk 

4543 if cls.viewable: 

4544 instance_url_prefix = cls.get_instance_url_prefix() 

4545 config.add_route(f"{route_prefix}.view", instance_url_prefix) 

4546 config.add_view( 

4547 cls, 

4548 attr="view", 

4549 route_name=f"{route_prefix}.view", 

4550 permission=f"{permission_prefix}.view", 

4551 ) 

4552 config.add_wutta_permission( 

4553 permission_prefix, f"{permission_prefix}.view", f"View {model_title}" 

4554 ) 

4555 

4556 # version history 

4557 if cls.is_versioned() and app.continuum_is_enabled(): 

4558 instance_url_prefix = cls.get_instance_url_prefix() 

4559 config.add_wutta_permission( 

4560 permission_prefix, 

4561 f"{permission_prefix}.versions", 

4562 f"View version history for {model_title}", 

4563 ) 

4564 config.add_route( 

4565 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/" 

4566 ) 

4567 config.add_view( 

4568 cls, 

4569 attr="view_versions", 

4570 route_name=f"{route_prefix}.versions", 

4571 permission=f"{permission_prefix}.versions", 

4572 ) 

4573 config.add_route( 

4574 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}" 

4575 ) 

4576 config.add_view( 

4577 cls, 

4578 attr="view_version", 

4579 route_name=f"{route_prefix}.version", 

4580 permission=f"{permission_prefix}.versions", 

4581 ) 

4582 

4583 ############################## 

4584 # row-specific routes 

4585 ############################## 

4586 

4587 # create row 

4588 if cls.has_rows and cls.rows_creatable: 

4589 config.add_wutta_permission( 

4590 permission_prefix, 

4591 f"{permission_prefix}.create_row", 

4592 f'Create new "rows" for {model_title}', 

4593 ) 

4594 config.add_route( 

4595 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row" 

4596 ) 

4597 config.add_view( 

4598 cls, 

4599 attr="create_row", 

4600 route_name=f"{route_prefix}.create_row", 

4601 permission=f"{permission_prefix}.create_row", 

4602 )