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

1359 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 13:36 -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 

27# pylint: disable=too-many-lines 

28 

29import logging 

30import os 

31import threading 

32import warnings 

33from uuid import UUID 

34 

35import sqlalchemy as sa 

36from sqlalchemy import orm 

37 

38from pyramid.renderers import render_to_response 

39from webhelpers2.html import HTML, tags 

40 

41from wuttjamaican.util import get_class_hierarchy 

42from wuttaweb.views.base import View 

43from wuttaweb.util import get_form_data, render_csrf_token 

44from wuttaweb.db import Session 

45from wuttaweb.progress import SessionProgress 

46from wuttaweb.diffs import MergeDiff, VersionDiff 

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:: Session 

79 

80 Reference to the view's :term:`db session` factory. By default 

81 this will correspond to the :term:`app database` but a subclass 

82 is free to use a different one where needed. 

83 

84 The built-in default is a 

85 :class:`~sqlalchemy:sqlalchemy.orm.scoped_session` instance. 

86 (Actually it's a reference to 

87 :class:`~wuttaweb.db.sess.Session`.) Code may reference it 

88 with or without "calling" it per se, although explicitly 

89 calling it is preferred:: 

90 

91 def some_view(self): 

92 model = self.app.model 

93 

94 # referencing as session instance 

95 widgets = self.Session.query(model.Widget).all() 

96 

97 # calling as factory 

98 session = self.Session() 

99 widgets = session.query(model.Widget).all() 

100 

101 .. attribute:: model_class 

102 

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

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

105 mapped class, 

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

107 

108 Code should not access this directly but instead call 

109 :meth:`get_model_class()`. 

110 

111 .. attribute:: model_name 

112 

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

114 e.g. ``'WuttaWidget'``. 

115 

116 Code should not access this directly but instead call 

117 :meth:`get_model_name()`. 

118 

119 .. attribute:: model_name_normalized 

120 

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

122 e.g. ``'wutta_widget'``. 

123 

124 Code should not access this directly but instead call 

125 :meth:`get_model_name_normalized()`. 

126 

127 .. attribute:: model_title 

128 

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

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

131 

132 Code should not access this directly but instead call 

133 :meth:`get_model_title()`. 

134 

135 .. attribute:: model_title_plural 

136 

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

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

139 

140 Code should not access this directly but instead call 

141 :meth:`get_model_title_plural()`. 

142 

143 .. attribute:: model_key 

144 

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

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

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

148 

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

150 model key can be determined automatically. 

151 

152 Code should not access this directly but instead call 

153 :meth:`get_model_key()`. 

154 

155 .. attribute:: grid_key 

156 

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

158 

159 Code should not access this directly but instead call 

160 :meth:`get_grid_key()`. 

161 

162 .. attribute:: config_title 

163 

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

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

166 

167 Code should not access this directly but instead call 

168 :meth:`get_config_title()`. 

169 

170 .. attribute:: route_prefix 

171 

172 Optional override for the view's route prefix, 

173 e.g. ``'wutta_widgets'``. 

174 

175 Code should not access this directly but instead call 

176 :meth:`get_route_prefix()`. 

177 

178 .. attribute:: permission_prefix 

179 

180 Optional override for the view's permission prefix, 

181 e.g. ``'wutta_widgets'``. 

182 

183 Code should not access this directly but instead call 

184 :meth:`get_permission_prefix()`. 

185 

186 .. attribute:: url_prefix 

187 

188 Optional override for the view's URL prefix, 

189 e.g. ``'/widgets'``. 

190 

191 Code should not access this directly but instead call 

192 :meth:`get_url_prefix()`. 

193 

194 .. attribute:: template_prefix 

195 

196 Optional override for the view's template prefix, 

197 e.g. ``'/widgets'``. 

198 

199 Code should not access this directly but instead call 

200 :meth:`get_template_prefix()`. 

201 

202 .. attribute:: listable 

203 

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

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

206 ``True``. 

207 

208 .. attribute:: has_grid 

209 

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

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

212 

213 .. attribute:: grid_columns 

214 

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

216 

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

218 

219 .. attribute:: has_grid_totals 

220 

221 Boolean indicating whether the main grid supports a "Show 

222 Totals" feature; this is false by default. 

223 

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

225 

226 .. attribute:: checkable 

227 

228 Boolean indicating whether the grid should expose per-row 

229 checkboxes. This is passed along to set 

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

231 

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

233 

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

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

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

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

238 

239 For more info see 

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

241 

242 .. attribute:: filterable 

243 

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

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

246 

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

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

249 

250 .. attribute:: filter_defaults 

251 

252 Optional dict of default filter state. 

253 

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

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

256 

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

258 

259 .. attribute:: sortable 

260 

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

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

263 

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

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

266 

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

268 

269 .. attribute:: sort_on_backend 

270 

271 Boolean indicating whether the grid data for the 

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

273 is ``True``. 

274 

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

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

277 

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

279 

280 .. attribute:: sort_defaults 

281 

282 Optional list of default sorting info. Applicable for both 

283 frontend and backend sorting. 

284 

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

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

287 

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

289 

290 .. attribute:: paginated 

291 

292 Boolean indicating whether the grid data for the 

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

294 

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

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

297 

298 .. attribute:: paginate_on_backend 

299 

300 Boolean indicating whether the grid data for the 

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

302 Default is ``True``. 

303 

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

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

306 

307 .. attribute:: creatable 

308 

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

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

311 ``True``. 

312 

313 .. attribute:: viewable 

314 

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

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

317 ``True``. 

318 

319 .. attribute:: editable 

320 

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

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

323 ``True``. 

324 

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

326 

327 .. attribute:: deletable 

328 

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

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

331 ``True``. 

332 

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

334 

335 .. attribute:: deletable_bulk 

336 

337 Boolean indicating whether the view model supports "bulk 

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

339 Default value is ``False``. 

340 

341 See also :attr:`deletable_bulk_quick`. 

342 

343 .. attribute:: deletable_bulk_quick 

344 

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

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

347 should happen *synchronously* with no progress indicator. 

348 

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

350 shown while the bulk deletion is performed. 

351 

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

353 

354 .. attribute:: form_fields 

355 

356 List of fields for the model form. 

357 

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

359 

360 .. attribute:: has_autocomplete 

361 

362 Boolean indicating whether the view model supports 

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

364 view. Default is ``False``. 

365 

366 .. attribute:: downloadable 

367 

368 Boolean indicating whether the view model supports 

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

370 Default is ``False``. 

371 

372 .. attribute:: executable 

373 

374 Boolean indicating whether the view model supports "executing" 

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

376 ``False``. 

377 

378 .. attribute:: configurable 

379 

380 Boolean indicating whether the master view supports 

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

382 Default value is ``False``. 

383 

384 .. attribute:: version_grid_columns 

385 

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

387 

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

389 

390 .. attribute:: mergeable 

391 

392 Boolean indicating whether the view model supports "merging two 

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

394 value is ``False``. 

395 

396 .. attribute:: merge_additive_fields 

397 

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

399 nature when merging two records. Only relevant if 

400 :attr:`mergeable` is true. 

401 

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

403 

404 .. attribute:: merge_coalesce_fields 

405 

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

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

408 is true. 

409 

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

411 

412 .. attribute:: merge_simple_fields 

413 

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

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

416 

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

418 

419 **ROW FEATURES** 

420 

421 .. attribute:: has_rows 

422 

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

424 displayed when viewing model records. For instance when 

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

426 as well as its row data. 

427 

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

429 on then many other things kick in. 

430 

431 See also :attr:`row_model_class`. 

432 

433 .. attribute:: row_model_class 

434 

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

436 

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

438 

439 View logic should not access this directly but instead call 

440 :meth:`get_row_model_class()`. 

441 

442 .. attribute:: row_model_key 

443 

444 Optional override for the view's "row model key" - 

445 e.g. ``"id"`` (string for simple case) or composite key such as 

446 ``("id_field", "name_field")``. 

447 

448 If :attr:`row_model_class` is set to a SQLAlchemy mapped class, 

449 the row model key can be determined automatically. Otherwise 

450 the view class should set it explicitly. 

451 

452 Code should not access this directly but instead call 

453 :meth:`get_row_model_key()`. 

454 

455 .. attribute:: row_model_name 

456 

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

458 e.g. ``'WuttaWidget'``. 

459 

460 Code should not access this directly but instead call 

461 :meth:`get_row_model_name()`. 

462 

463 .. attribute:: row_model_title 

464 

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

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

467 

468 Code should not access this directly but instead call 

469 :meth:`get_row_model_title()`. 

470 

471 .. attribute:: row_model_title_plural 

472 

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

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

475 

476 Code should not access this directly but instead call 

477 :meth:`get_row_model_title_plural()`. 

478 

479 .. attribute:: rows_title 

480 

481 Optional explicit display title for the rows grid. 

482 

483 Callers should typically not access this directly but instead 

484 call :meth:`get_rows_title()`. 

485 

486 .. attribute:: row_grid_columns 

487 

488 List of columns for the row grid. 

489 

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

491 

492 .. attribute:: rows_sortable 

493 

494 Boolean indicating whether the **rows** grid for the 

495 :meth:`view()` view should allow sorting of data. Default is 

496 ``True``. 

497 

498 This is used by :meth:`make_row_model_grid()` to set the grid's 

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

500 

501 See also :attr:`rows_sort_on_backend` and 

502 :attr:`rows_sort_defaults`. 

503 

504 .. attribute:: rows_sort_on_backend 

505 

506 Boolean indicating whether the **rows** grid data for the 

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

508 is ``True``. 

509 

510 This is used by :meth:`make_row_model_grid()` to set the grid's 

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

512 

513 Only relevant if :attr:`rows_sortable` is true. 

514 

515 .. attribute:: rows_sort_defaults 

516 

517 Optional list of default sorting info for the **rows** grid. 

518 Applicable for both frontend and backend sorting. 

519 

520 This is used by :meth:`make_row_model_grid()` to set the grid's 

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

522 

523 Only relevant if :attr:`rows_sortable` is true. 

524 

525 .. attribute:: rows_viewable 

526 

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

528 i.e. there should be a :meth:`view_row()` view, and the row 

529 grid should include a "View" action link. Default is 

530 ``False``. 

531 

532 See also :meth:`get_row_action_url_view()`. 

533 

534 .. attribute:: rows_editable 

535 

536 Boolean indicating whether the row model supports "editing" - 

537 i.e. there should be an :meth:`edit_row()` view, and the row 

538 grid should include an "Edit" action link. Default is 

539 ``False``. 

540 

541 See also :meth:`is_row_editable()`. 

542 

543 .. attribute:: rows_deletable 

544 

545 Boolean indicating whether the row model supports "deleting" - 

546 i.e. there should be a :meth:`delete_row()` view, and the row 

547 grid should include a "Delete" action link. Default is 

548 ``False``. 

549 

550 See also :meth:`is_row_deletable()`. 

551 

552 .. attribute:: row_form_fields 

553 

554 List of fields for the row model form. 

555 

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

557 

558 .. attribute:: rows_creatable 

559 

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

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

562 Default value is ``False``. 

563 """ 

564 

565 ############################## 

566 # attributes 

567 ############################## 

568 

569 model_class = None 

570 

571 # features 

572 listable = True 

573 has_grid = True 

574 has_grid_totals = False 

575 checkable = False 

576 filterable = True 

577 filter_defaults = None 

578 sortable = True 

579 sort_on_backend = True 

580 sort_defaults = None 

581 paginated = True 

582 paginate_on_backend = True 

583 creatable = True 

584 viewable = True 

585 editable = True 

586 deletable = True 

587 deletable_bulk = False 

588 deletable_bulk_quick = False 

589 has_autocomplete = False 

590 downloadable = False 

591 executable = False 

592 execute_progress_template = None 

593 configurable = False 

594 

595 # merging 

596 mergeable = False 

597 merge_additive_fields = None 

598 merge_coalesce_fields = None 

599 merge_simple_fields = None 

600 

601 # row features 

602 has_rows = False 

603 row_model_class = None 

604 rows_filterable = True 

605 rows_filter_defaults = None 

606 rows_sortable = True 

607 rows_sort_on_backend = True 

608 rows_sort_defaults = None 

609 rows_paginated = True 

610 rows_paginate_on_backend = True 

611 rows_creatable = False 

612 rows_viewable = False 

613 rows_editable = False 

614 rows_deletable = False 

615 

616 # current action 

617 listing = False 

618 creating = False 

619 viewing = False 

620 editing = False 

621 deleting = False 

622 executing = False 

623 configuring = False 

624 

625 # default DB session 

626 Session = Session 

627 

628 ############################## 

629 # index methods 

630 ############################## 

631 

632 def index(self): 

633 """ 

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

635 

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

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

638 e.g. ``/widgets/``. 

639 

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

641 true. 

642 

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

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

645 

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

647 

648 * :meth:`make_model_grid()` 

649 """ 

650 self.listing = True 

651 

652 context = { 

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

654 } 

655 

656 if self.has_grid: 

657 grid = self.make_model_grid() 

658 

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

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

661 

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

663 context = grid.get_vue_context() 

664 if grid.paginated and grid.paginate_on_backend: 

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

666 return self.json_response(context) 

667 

668 # full, not partial 

669 

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

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

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

673 

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

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

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

677 

678 context["grid"] = grid 

679 

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

681 

682 def fetch_grid_totals(self): 

683 """ 

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

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

686 true. 

687 

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

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

690 displayed on the page. 

691 

692 Subclass should override this method to calculate and return 

693 the customized info. Default logic within the template is 

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

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

696 

697 def fetch_grid_totals(self): 

698 

699 from webhelpers2.html import HTML 

700 

701 # get current data set from grid 

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

703 # paginated; we want to include all results. 

704 grid = self.make_model_grid(paginated=False) 

705 rows = grid.get_visible_data() 

706 

707 # calculate total 

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

709 

710 # render as <span> tag 

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

712 return {"totals_html": html} 

713 

714 :returns: Dict of totals info. 

715 """ 

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

717 

718 ############################## 

719 # create methods 

720 ############################## 

721 

722 def create(self): 

723 """ 

724 View to "create" a new model record. 

725 

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

727 

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

729 is true. 

730 

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

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

733 :meth:`save_create_form()` and then 

734 :meth:`redirect_after_create()`. 

735 """ 

736 self.creating = True 

737 form = self.make_create_form() 

738 

739 if form.validate(): 

740 session = self.Session() 

741 try: 

742 result = self.save_create_form(form) 

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

744 session.flush() 

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

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

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

748 else: 

749 return self.redirect_after_create(result) 

750 

751 context = {"form": form} 

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

753 

754 def make_create_form(self): 

755 """ 

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

757 :meth:`create()`. 

758 

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

760 

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

762 """ 

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

764 

765 def save_create_form(self, form): 

766 """ 

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

768 

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

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

771 non-standard use cases. 

772 

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

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

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

776 :meth:`redirect_after_create()`. 

777 

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

779 """ 

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

781 warnings.warn( 

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

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

784 DeprecationWarning, 

785 ) 

786 return self.create_save_form(form) 

787 

788 obj = self.objectify(form) 

789 self.persist(obj) 

790 return obj 

791 

792 def redirect_after_create(self, result): 

793 """ 

794 Must return a redirect, following successful save of the 

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

796 

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

798 record. 

799 

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

801 """ 

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

803 

804 ############################## 

805 # view methods 

806 ############################## 

807 

808 def view(self): 

809 """ 

810 View to "view" a model record. 

811 

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

813 

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

815 true. 

816 

817 The default logic here is as follows: 

818 

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

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

821 

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

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

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

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

826 sorting and pagination etc.) 

827 

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

829 is rendered. 

830 """ 

831 self.viewing = True 

832 obj = self.get_instance() 

833 context = {"instance": obj} 

834 

835 if self.has_rows: 

836 

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

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

839 grid = self.make_row_model_grid(obj) 

840 

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

842 # redirect so the query string gets cleared out 

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

844 

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

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

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

848 

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

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

851 context = grid.get_vue_context() 

852 if grid.paginated and grid.paginate_on_backend: 

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

854 return self.json_response(context) 

855 

856 context["rows_grid"] = grid 

857 

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

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

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

861 

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

863 """ 

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

865 :meth:`view()`. 

866 

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

868 

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

870 """ 

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

872 

873 ############################## 

874 # edit methods 

875 ############################## 

876 

877 def edit(self): 

878 """ 

879 View to "edit" a model record. 

880 

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

882 

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

884 true. 

885 

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

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

888 :meth:`save_edit_form()` and then 

889 :meth:`redirect_after_edit()`. 

890 """ 

891 self.editing = True 

892 instance = self.get_instance() 

893 form = self.make_edit_form(instance) 

894 

895 if form.validate(): 

896 try: 

897 result = self.save_edit_form(form) 

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

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

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

901 else: 

902 return self.redirect_after_edit(result) 

903 

904 context = { 

905 "instance": instance, 

906 "form": form, 

907 } 

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

909 

910 def make_edit_form(self, obj): 

911 """ 

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

913 :meth:`edit()`. 

914 

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

916 

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

918 """ 

919 return self.make_model_form( 

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

921 ) 

922 

923 def save_edit_form(self, form): 

924 """ 

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

926 

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

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

929 non-standard use cases. 

930 

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

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

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

934 :meth:`redirect_after_edit()`. 

935 

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

937 """ 

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

939 warnings.warn( 

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

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

942 DeprecationWarning, 

943 ) 

944 return self.edit_save_form(form) 

945 

946 obj = self.objectify(form) 

947 self.persist(obj) 

948 return obj 

949 

950 def redirect_after_edit(self, result): 

951 """ 

952 Must return a redirect, following successful save of the 

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

954 

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

956 

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

958 """ 

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

960 

961 ############################## 

962 # delete methods 

963 ############################## 

964 

965 def delete(self): 

966 """ 

967 View to "delete" a model record. 

968 

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

970 

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

972 is true. 

973 

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

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

976 :meth:`save_delete_form()` and then 

977 :meth:`redirect_after_delete()`. 

978 """ 

979 self.deleting = True 

980 instance = self.get_instance() 

981 

982 if not self.is_deletable(instance): 

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

984 

985 form = self.make_delete_form(instance) 

986 

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

988 if form.validate() is not False: 

989 

990 try: 

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

992 form 

993 ) 

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

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

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

997 else: 

998 return self.redirect_after_delete(result) 

999 

1000 context = { 

1001 "instance": instance, 

1002 "form": form, 

1003 } 

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

1005 

1006 def make_delete_form(self, obj): 

1007 """ 

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

1009 :meth:`delete()`. 

1010 

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

1012 twist: 

1013 

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

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

1016 explicitly marked readonly. 

1017 

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

1019 """ 

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

1021 form = self.make_model_form( 

1022 obj, 

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

1024 button_label_submit="DELETE Forever", 

1025 button_icon_submit="trash", 

1026 button_type_submit="is-danger", 

1027 ) 

1028 

1029 # ..but *all* fields are readonly 

1030 for field in form.fields: 

1031 form.set_readonly(field) 

1032 

1033 return form 

1034 

1035 def save_delete_form(self, form): 

1036 """ 

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

1038 

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

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

1041 it could also/instead override this method. 

1042 

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

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

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

1046 

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

1048 """ 

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

1050 warnings.warn( 

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

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

1053 DeprecationWarning, 

1054 ) 

1055 self.delete_save_form(form) 

1056 return 

1057 

1058 obj = form.model_instance 

1059 self.delete_instance(obj) 

1060 

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

1062 """ 

1063 Must return a redirect, following successful save of the 

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

1065 

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

1067 

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

1069 """ 

1070 return self.redirect(self.get_index_url()) 

1071 

1072 def delete_instance(self, obj): 

1073 """ 

1074 Delete the given model instance. 

1075 

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

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

1078 needed. 

1079 

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

1081 """ 

1082 session = self.app.get_session(obj) 

1083 session.delete(obj) 

1084 

1085 def delete_bulk(self): 

1086 """ 

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

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

1089 

1090 This usually corresponds to a URL like 

1091 ``/widgets/delete-bulk``. 

1092 

1093 By default, this view is included only if 

1094 :attr:`deletable_bulk` is true. 

1095 

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

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

1098 

1099 Subclass normally should not override this method, but rather 

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

1101 this one: 

1102 

1103 * :meth:`delete_bulk_action()` 

1104 """ 

1105 

1106 # get current data set from grid 

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

1108 grid = self.make_model_grid(paginated=False) 

1109 data = grid.get_visible_data() 

1110 

1111 if self.deletable_bulk_quick: 

1112 

1113 # delete it all and go back to listing 

1114 self.delete_bulk_action(data) 

1115 return self.redirect(self.get_index_url()) 

1116 

1117 # start thread for delete; show progress page 

1118 route_prefix = self.get_route_prefix() 

1119 key = f"{route_prefix}.delete_bulk" 

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

1121 thread = threading.Thread( 

1122 target=self.delete_bulk_thread, 

1123 args=(data,), 

1124 kwargs={"progress": progress}, 

1125 ) 

1126 thread.start() 

1127 return self.render_progress(progress) 

1128 

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

1130 self, query, progress=None 

1131 ): 

1132 """ """ 

1133 session = self.app.make_session() 

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

1135 

1136 def onerror(): 

1137 log.warning( 

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

1139 len(records), 

1140 self.get_model_title_plural(), 

1141 exc_info=True, 

1142 ) 

1143 

1144 self.do_thread_body( 

1145 self.delete_bulk_action, 

1146 (records,), 

1147 {"progress": progress}, 

1148 onerror, 

1149 session=session, 

1150 progress=progress, 

1151 ) 

1152 

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

1154 """ 

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

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

1157 

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

1159 record, and if that returns true then it calls 

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

1161 updated if one is provided. 

1162 

1163 Subclass should override if needed. 

1164 """ 

1165 model_title_plural = self.get_model_title_plural() 

1166 

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

1168 if self.is_deletable(obj): 

1169 self.delete_instance(obj) 

1170 

1171 self.app.progress_loop( 

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

1173 ) 

1174 

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

1176 """ """ 

1177 route_prefix = self.get_route_prefix() 

1178 

1179 label = HTML.literal( 

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

1181 ) 

1182 button = self.make_button( 

1183 label, 

1184 variant="is-danger", 

1185 icon_left="trash", 

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

1187 ) 

1188 

1189 form = HTML.tag( 

1190 "form", 

1191 method="post", 

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

1193 ref="deleteResultsForm", 

1194 class_="control", 

1195 c=[ 

1196 render_csrf_token(self.request), 

1197 button, 

1198 ], 

1199 ) 

1200 return form 

1201 

1202 ############################## 

1203 # version history methods 

1204 ############################## 

1205 

1206 @classmethod 

1207 def is_versioned(cls): 

1208 """ 

1209 Returns boolean indicating whether the model class is 

1210 configured for SQLAlchemy-Continuum versioning. 

1211 

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

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

1214 setting the ``model_is_versioned`` attribute:: 

1215 

1216 class WidgetView(MasterView): 

1217 model_class = Widget 

1218 model_is_versioned = False 

1219 

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

1221 

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

1223 ``False``. 

1224 """ 

1225 if hasattr(cls, "model_is_versioned"): 

1226 return cls.model_is_versioned 

1227 

1228 model_class = cls.get_model_class() 

1229 if hasattr(model_class, "__versioned__"): 

1230 return True 

1231 

1232 return False 

1233 

1234 @classmethod 

1235 def get_model_version_class(cls): 

1236 """ 

1237 Returns the version class for the master model class. 

1238 

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

1240 """ 

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

1242 

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

1244 

1245 def should_expose_versions(self): 

1246 """ 

1247 Returns boolean indicating whether versioning history should 

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

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

1250 

1251 * :meth:`is_versioned()` 

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

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

1254 

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

1256 user; else ``False``. 

1257 """ 

1258 if not self.is_versioned(): 

1259 return False 

1260 

1261 if not self.app.continuum_is_enabled(): 

1262 return False 

1263 

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

1265 return False 

1266 

1267 return True 

1268 

1269 def view_versions(self): 

1270 """ 

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

1272 :meth:`view_version()`. 

1273 

1274 This usually corresponds to a URL like 

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

1276 for the record. 

1277 

1278 By default, this view is included only if 

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

1280 

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

1282 record's version history. 

1283 

1284 See also: 

1285 

1286 * :meth:`make_version_grid()` 

1287 """ 

1288 instance = self.get_instance() 

1289 instance_title = self.get_instance_title(instance) 

1290 grid = self.make_version_grid(instance) 

1291 

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

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

1294 context = grid.get_vue_context() 

1295 if grid.paginated and grid.paginate_on_backend: 

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

1297 return self.json_response(context) 

1298 

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

1300 

1301 instance_link = tags.link_to( 

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

1303 ) 

1304 

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

1306 [index_link, instance_link] 

1307 ) 

1308 

1309 return self.render_to_response( 

1310 "view_versions", 

1311 { 

1312 "index_title_rendered": index_title_rendered, 

1313 "instance": instance, 

1314 "instance_title": instance_title, 

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

1316 "grid": grid, 

1317 }, 

1318 ) 

1319 

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

1321 """ 

1322 Create and return a grid for use with the 

1323 :meth:`view_versions()` view. 

1324 

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

1326 

1327 * :meth:`get_version_grid_key()` 

1328 * :meth:`get_version_grid_columns()` 

1329 * :meth:`get_version_grid_data()` 

1330 * :meth:`configure_version_grid()` 

1331 

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

1333 """ 

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

1335 

1336 route_prefix = self.get_route_prefix() 

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

1338 if not instance: 

1339 instance = self.get_instance() 

1340 

1341 if "key" not in kwargs: 

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

1343 

1344 if "model_class" not in kwargs: 

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

1346 

1347 if "columns" not in kwargs: 

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

1349 

1350 if "data" not in kwargs: 

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

1352 

1353 if "actions" not in kwargs: 

1354 route = f"{route_prefix}.version" 

1355 

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

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

1358 

1359 kwargs["actions"] = [ 

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

1361 ] 

1362 

1363 kwargs.setdefault("paginated", True) 

1364 

1365 grid = self.make_grid(**kwargs) 

1366 self.configure_version_grid(grid) 

1367 grid.load_settings() 

1368 return grid 

1369 

1370 @classmethod 

1371 def get_version_grid_key(cls): 

1372 """ 

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

1374 sort/filter options etc. 

1375 

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

1377 

1378 :returns: Grid key as string 

1379 """ 

1380 if hasattr(cls, "version_grid_key"): 

1381 return cls.version_grid_key 

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

1383 

1384 def get_version_grid_columns(self): 

1385 """ 

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

1387 :meth:`view_versions()` view. 

1388 

1389 This is normally called automatically by 

1390 :meth:`make_version_grid()`. 

1391 

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

1393 cases, or can override this method if needed. 

1394 

1395 :returns: List of string column names 

1396 """ 

1397 if hasattr(self, "version_grid_columns"): 

1398 return self.version_grid_columns 

1399 

1400 return [ 

1401 "id", 

1402 "issued_at", 

1403 "user", 

1404 "remote_addr", 

1405 "comment", 

1406 ] 

1407 

1408 def get_version_grid_data(self, instance): 

1409 """ 

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

1411 view. 

1412 

1413 This is normally called automatically by 

1414 :meth:`make_version_grid()`. 

1415 

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

1417 records which are associated with versions of the given model 

1418 instance. See also: 

1419 

1420 * :meth:`get_version_joins()` 

1421 * :meth:`normalize_version_joins()` 

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

1423 

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

1425 """ 

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

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

1428 model_transaction_query, 

1429 ) 

1430 

1431 model_class = self.get_model_class() 

1432 txncls = continuum.transaction_class(model_class) 

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

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

1435 

1436 def get_version_joins(self): 

1437 """ 

1438 Override this method to declare additional version tables 

1439 which should be joined when showing the overall revision 

1440 history for a given model instance. 

1441 

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

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

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

1445 

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

1447 

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

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

1450 

1451 def get_version_joins(self): 

1452 model = self.app.model 

1453 return super().get_version_joins() + [ 

1454 model.UserExtension, 

1455 ] 

1456 

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

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

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

1460 

1461 def get_version_joins(self): 

1462 model = self.app.model 

1463 return super().get_version_joins() + [ 

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

1465 ] 

1466 

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

1468 """ 

1469 return [] 

1470 

1471 def normalize_version_joins(self): 

1472 """ 

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

1474 the result, which will then get passed along to 

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

1476 

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

1478 override :meth:`get_version_joins()`. 

1479 

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

1481 conforming to what is needed for the query function. 

1482 

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

1484 

1485 :returns: List of version joins info. 

1486 """ 

1487 joins = [] 

1488 for join in self.get_version_joins(): 

1489 if not isinstance(join, tuple): 

1490 join = (join, "uuid", "uuid") 

1491 joins.append(join) 

1492 return joins 

1493 

1494 def configure_version_grid(self, g): 

1495 """ 

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

1497 

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

1499 

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

1501 """ 

1502 # id 

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

1504 # g.set_link("id") 

1505 

1506 # issued_at 

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

1508 g.set_link("issued_at") 

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

1510 

1511 # user 

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

1513 g.set_link("user") 

1514 

1515 # remote_addr 

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

1517 

1518 # comment 

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

1520 

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

1522 self, txn, key, value 

1523 ): 

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

1525 

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

1527 """ 

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

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

1530 

1531 This usually corresponds to a URL like 

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

1533 key/ID for the record and YYY represents a 

1534 SQLAlchemy-Continuum ``transaction.id``. 

1535 

1536 By default, this view is included only if 

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

1538 

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

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

1541 

1542 See also: 

1543 

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

1545 * :meth:`get_relevant_versions()` 

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

1547 """ 

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

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

1550 model_transaction_query, 

1551 ) 

1552 

1553 instance = self.get_instance() 

1554 model_class = self.get_model_class() 

1555 route_prefix = self.get_route_prefix() 

1556 txncls = continuum.transaction_class(model_class) 

1557 transactions = model_transaction_query( 

1558 instance, joins=self.normalize_version_joins() 

1559 ) 

1560 

1561 txnid = int(self.request.matchdict["txnid"]) 

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

1563 if not txn: 

1564 raise self.notfound() 

1565 

1566 prev_url = None 

1567 older = ( 

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

1569 .filter(txncls.id != txnid) 

1570 .order_by(txncls.issued_at.desc()) 

1571 .first() 

1572 ) 

1573 if older: 

1574 prev_url = self.request.route_url( 

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

1576 ) 

1577 

1578 next_url = None 

1579 newer = ( 

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

1581 .filter(txncls.id != txnid) 

1582 .order_by(txncls.issued_at) 

1583 .first() 

1584 ) 

1585 if newer: 

1586 next_url = self.request.route_url( 

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

1588 ) 

1589 

1590 version_diffs = [ 

1591 VersionDiff(self.config, version) 

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

1593 ] 

1594 

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

1596 

1597 instance_title = self.get_instance_title(instance) 

1598 instance_link = tags.link_to( 

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

1600 ) 

1601 

1602 history_link = tags.link_to( 

1603 "History", 

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

1605 ) 

1606 

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

1608 [index_link, instance_link, history_link] 

1609 ) 

1610 

1611 return self.render_to_response( 

1612 "view_version", 

1613 { 

1614 "index_title_rendered": index_title_rendered, 

1615 "instance": instance, 

1616 "instance_title": instance_title, 

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

1618 "transaction": txn, 

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

1620 "version_diffs": version_diffs, 

1621 "show_prev_next": True, 

1622 "prev_url": prev_url, 

1623 "next_url": next_url, 

1624 }, 

1625 ) 

1626 

1627 def get_relevant_versions(self, transaction, instance): 

1628 """ 

1629 Should return all version records pertaining to the given 

1630 model instance and transaction. 

1631 

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

1633 

1634 :param transaction: SQLAlchemy-Continuum ``transaction`` 

1635 record/instance. 

1636 

1637 :param instance: Instance of the model class. 

1638 

1639 :returns: List of version records. 

1640 """ 

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

1642 

1643 session = self.Session() 

1644 vercls = self.get_model_version_class() 

1645 versions = [] 

1646 

1647 # first get all versions for the model instance proper 

1648 versions.extend( 

1649 session.query(vercls) 

1650 .filter(vercls.transaction == transaction) 

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

1652 .all() 

1653 ) 

1654 

1655 # then get all related versions, per declared joins 

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

1657 child_vercls = continuum.version_class(child_class) 

1658 versions.extend( 

1659 session.query(child_vercls) 

1660 .filter(child_vercls.transaction == transaction) 

1661 .filter( 

1662 getattr(child_vercls, foreign_attr) 

1663 == getattr(instance, primary_attr) 

1664 ) 

1665 ) 

1666 

1667 return versions 

1668 

1669 ############################## 

1670 # autocomplete methods 

1671 ############################## 

1672 

1673 def autocomplete(self): 

1674 """ 

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

1676 list of autocomplete results to match. 

1677 

1678 By default, this view is included only if 

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

1680 like ``/widgets/autocomplete``. 

1681 

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

1683 rather should override the others which this calls: 

1684 

1685 * :meth:`autocomplete_data()` 

1686 * :meth:`autocomplete_normalize()` 

1687 """ 

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

1689 if not term: 

1690 return [] 

1691 

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

1693 if not data: 

1694 return [] 

1695 

1696 max_results = 100 # TODO 

1697 

1698 results = [] 

1699 for obj in data[:max_results]: 

1700 normal = self.autocomplete_normalize(obj) 

1701 if normal: 

1702 results.append(normal) 

1703 

1704 return results 

1705 

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

1707 """ 

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

1709 based on autocomplete search term. This is called by 

1710 :meth:`autocomplete()`. 

1711 

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

1713 

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

1715 

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

1717 """ 

1718 return None 

1719 

1720 def autocomplete_normalize(self, obj): 

1721 """ 

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

1723 record, suitable for autocomplete JSON results. This is 

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

1725 

1726 Subclass may need to override this; default logic is 

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

1728 "autocomplete results" dict for the object:: 

1729 

1730 { 

1731 'value': obj.uuid, 

1732 'label': str(obj), 

1733 } 

1734 

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

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

1737 

1738 :param obj: Model record/instance. 

1739 

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

1741 above. 

1742 """ 

1743 return { 

1744 "value": obj.uuid, 

1745 "label": str(obj), 

1746 } 

1747 

1748 ############################## 

1749 # download methods 

1750 ############################## 

1751 

1752 def download(self): 

1753 """ 

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

1755 

1756 This usually corresponds to a URL like 

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

1758 for the record. 

1759 

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

1761 is true. 

1762 

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

1764 it as a file download response to the client. 

1765 

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

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

1768 files associated with the model record. This filename is 

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

1770 

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

1772 

1773 Subclass normally should not override this method, but rather 

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

1775 this one: 

1776 

1777 * :meth:`download_path()` 

1778 """ 

1779 obj = self.get_instance() 

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

1781 

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

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

1784 return self.notfound() 

1785 

1786 return self.file_response(path) 

1787 

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

1789 """ 

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

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

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

1793 

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

1795 

1796 :param obj: Refefence to the model instance. 

1797 

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

1799 

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

1801 

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

1803 file path should be returned, if applicable. 

1804 

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

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

1807 response. 

1808 """ 

1809 return None 

1810 

1811 ############################## 

1812 # execute methods 

1813 ############################## 

1814 

1815 def execute(self): 

1816 """ 

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

1818 

1819 This usually corresponds to a URL like 

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

1821 for the record. 

1822 

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

1824 true. 

1825 

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

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

1828 

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

1830 * batches (not yet implemented; 

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

1832 Manual) 

1833 

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

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

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

1837 live" the data held within the batch. 

1838 

1839 Subclass normally should not override this method, but rather 

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

1841 this one: 

1842 

1843 * :meth:`execute_instance()` 

1844 """ 

1845 route_prefix = self.get_route_prefix() 

1846 model_title = self.get_model_title() 

1847 obj = self.get_instance() 

1848 

1849 # make the progress tracker 

1850 progress = self.make_progress( 

1851 f"{route_prefix}.execute", 

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

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

1854 ) 

1855 

1856 # start thread for execute; show progress page 

1857 key = self.request.matchdict 

1858 thread = threading.Thread( 

1859 target=self.execute_thread, 

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

1861 kwargs={"progress": progress}, 

1862 ) 

1863 thread.start() 

1864 return self.render_progress( 

1865 progress, 

1866 context={ 

1867 "instance": obj, 

1868 }, 

1869 template=self.execute_progress_template, 

1870 ) 

1871 

1872 def execute_instance( 

1873 self, obj, user, progress=None 

1874 ): # pylint: disable=unused-argument 

1875 """ 

1876 This method must perform the actual (e.g. custom) "execution" 

1877 logic for a model instance. It is called from within a 

1878 secondary thread which is launched from :meth:`execute()`. 

1879 

1880 If execution succeeds, this method can return a string, which 

1881 will be shown to the user as-is via flash message. (It can 

1882 also return ``None`` in which case a generic success message 

1883 is shown.) 

1884 

1885 If execution fails, this method should raise an error which 

1886 will be caught and handled elsewhere. 

1887 

1888 :param obj: Reference to the model instance. 

1889 

1890 :param user: Reference to the 

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

1892 is doing the execute. 

1893 

1894 :param progress: Optional progress indicator factory. 

1895 

1896 :returns: Success message, or ``None`` 

1897 """ 

1898 return None 

1899 

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

1901 self, key, user_uuid, progress=None 

1902 ): 

1903 """ """ 

1904 model = self.app.model 

1905 model_title = self.get_model_title() 

1906 

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

1908 session = self.app.make_session() 

1909 

1910 # fetch model instance and user for this session 

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

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

1913 

1914 try: 

1915 success_msg = self.execute_instance( # pylint: disable=assignment-from-none 

1916 obj, user, progress=progress 

1917 ) 

1918 

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

1920 session.rollback() 

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

1922 if progress: 

1923 progress.handle_error(error) 

1924 

1925 else: 

1926 session.commit() 

1927 if progress: 

1928 progress.handle_success(success_msg=success_msg) 

1929 

1930 finally: 

1931 session.close() 

1932 

1933 ############################## 

1934 # configure methods 

1935 ############################## 

1936 

1937 def configure(self, session=None): 

1938 """ 

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

1940 this master view and/or model. 

1941 

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

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

1944 

1945 The expected workflow is as follows: 

1946 

1947 * user navigates to Configure page 

1948 * user modifies settings and clicks Save 

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

1950 * then it saves user-submitted settings 

1951 

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

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

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

1955 

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

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

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

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

1960 

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

1962 only provide their basic definitions via 

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

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

1965 to achieve the aim(s). 

1966 

1967 See also related methods, used by this one: 

1968 

1969 * :meth:`configure_get_simple_settings()` 

1970 * :meth:`configure_get_context()` 

1971 * :meth:`configure_gather_settings()` 

1972 * :meth:`configure_remove_settings()` 

1973 * :meth:`configure_save_settings()` 

1974 """ 

1975 self.configuring = True 

1976 config_title = self.get_config_title() 

1977 

1978 # was form submitted? 

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

1980 

1981 # maybe just remove settings 

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

1983 self.configure_remove_settings(session=session) 

1984 self.request.session.flash( 

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

1986 ) 

1987 

1988 # reload configure page 

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

1990 

1991 # gather/save settings 

1992 data = get_form_data(self.request) 

1993 settings = self.configure_gather_settings(data) 

1994 self.configure_remove_settings(session=session) 

1995 self.configure_save_settings(settings, session=session) 

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

1997 

1998 # reload configure page 

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

2000 

2001 # render configure page 

2002 context = self.configure_get_context() 

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

2004 

2005 def configure_get_context( 

2006 self, 

2007 simple_settings=None, 

2008 ): 

2009 """ 

2010 Returns the full context dict, for rendering the 

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

2012 

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

2014 to just name/value). 

2015 

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

2017 "complex" settings etc. 

2018 

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

2020 already initialized. Otherwise it is retrieved via 

2021 :meth:`configure_get_simple_settings()`. 

2022 

2023 :returns: Context dict for the page template. 

2024 """ 

2025 context = {} 

2026 

2027 # simple settings 

2028 if simple_settings is None: 

2029 simple_settings = self.configure_get_simple_settings() 

2030 if simple_settings: 

2031 

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

2033 normalized = {} 

2034 for simple in simple_settings: 

2035 

2036 # name 

2037 name = simple["name"] 

2038 

2039 # value 

2040 if "value" in simple: 

2041 value = simple["value"] 

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

2043 value = self.config.get_bool( 

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

2045 ) 

2046 else: 

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

2048 

2049 normalized[name] = value 

2050 

2051 # add to template context 

2052 context["simple_settings"] = normalized 

2053 

2054 return context 

2055 

2056 def configure_get_simple_settings(self): 

2057 """ 

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

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

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

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

2062 part of this method's return value.) 

2063 

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

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

2066 

2067 The setting definitions returned must each be a dict of 

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

2069 setting might be:: 

2070 

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

2072 

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

2074 is a more complete example:: 

2075 

2076 { 

2077 'name': 'wutta.production', 

2078 'type': bool, 

2079 'default': False, 

2080 'save_if_empty': False, 

2081 } 

2082 

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

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

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

2086 

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

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

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

2090 

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

2092 Note that their order does not matter since the template 

2093 must explicitly define field layout etc. 

2094 """ 

2095 return [] 

2096 

2097 def configure_gather_settings( 

2098 self, 

2099 data, 

2100 simple_settings=None, 

2101 ): 

2102 """ 

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

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

2105 

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

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

2108 the simple setting definitions. 

2109 

2110 Subclass may need to override this method if complex settings 

2111 are required. 

2112 

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

2114 

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

2116 already initialized. Otherwise it is retrieved via 

2117 :meth:`configure_get_simple_settings()`. 

2118 

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

2120 in spirit to the definition syntax used in 

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

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

2123 

2124 { 

2125 'name': 'wutta.app_title', 

2126 'value': 'Wutta Wutta', 

2127 } 

2128 

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

2130 

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

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

2133 contain all of them. 

2134 

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

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

2137 saved to DB) unless the setting definition has the 

2138 ``save_if_empty`` flag set. 

2139 """ 

2140 settings = [] 

2141 

2142 # simple settings 

2143 if simple_settings is None: 

2144 simple_settings = self.configure_get_simple_settings() 

2145 if simple_settings: 

2146 

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

2148 for simple in simple_settings: 

2149 name = simple["name"] 

2150 

2151 if name in data: 

2152 value = data[name] 

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

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

2155 value = False 

2156 else: 

2157 value = simple.get("default") 

2158 

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

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

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

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

2163 elif value is None: 

2164 value = "" 

2165 else: 

2166 value = str(value) 

2167 

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

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

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

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

2172 

2173 return settings 

2174 

2175 def configure_remove_settings( 

2176 self, 

2177 simple_settings=None, 

2178 session=None, 

2179 ): 

2180 """ 

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

2182 :meth:`configure()`. 

2183 

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

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

2186 

2187 The default logic can handle this automatically for simple 

2188 settings; subclass must override for any complex settings. 

2189 

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

2191 already initialized. Otherwise it is retrieved via 

2192 :meth:`configure_get_simple_settings()`. 

2193 """ 

2194 names = [] 

2195 

2196 # simple settings 

2197 if simple_settings is None: 

2198 simple_settings = self.configure_get_simple_settings() 

2199 if simple_settings: 

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

2201 

2202 if names: 

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

2204 # point to our primary app DB 

2205 session = session or self.Session() 

2206 for name in names: 

2207 self.app.delete_setting(session, name) 

2208 

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

2210 """ 

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

2212 :meth:`configure()`. 

2213 

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

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

2216 

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

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

2219 """ 

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

2221 # to our primary app DB 

2222 session = session or self.Session() 

2223 for setting in settings: 

2224 self.app.save_setting( 

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

2226 ) 

2227 

2228 ############################## 

2229 # grid rendering methods 

2230 ############################## 

2231 

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

2233 """ 

2234 Custom grid value renderer for "boolean" fields. 

2235 

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

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

2238 To use this feature for your grid:: 

2239 

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

2241 """ 

2242 if value is None: 

2243 return None 

2244 

2245 return "Yes" if value else "No" 

2246 

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

2248 """ 

2249 Custom grid value renderer for "currency" fields. 

2250 

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

2252 decimal as appropriate, and add the currency symbol. 

2253 

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

2255 default is 2 places. 

2256 

2257 To use this feature for your grid:: 

2258 

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

2260 

2261 # you can also override scale 

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

2263 """ 

2264 

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

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

2267 value = record[key] 

2268 

2269 if value is None: 

2270 return None 

2271 

2272 if value < 0: 

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

2274 return fmt.format(0 - value) 

2275 

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

2277 return fmt.format(value) 

2278 

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

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

2281 ): 

2282 """ """ 

2283 warnings.warn( 

2284 "MasterView.grid_render_datetime() is deprecated; " 

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

2286 DeprecationWarning, 

2287 stacklevel=2, 

2288 ) 

2289 

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

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

2292 value = record[key] 

2293 

2294 if value is None: 

2295 return None 

2296 

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

2298 

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

2300 """ 

2301 Custom grid value renderer for "enum" fields. 

2302 

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

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

2305 

2306 To use this feature for your grid:: 

2307 

2308 from enum import Enum 

2309 

2310 class MyEnum(Enum): 

2311 ONE = 1 

2312 TWO = 2 

2313 THREE = 3 

2314 

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

2316 """ 

2317 if enum: 

2318 original = record[key] 

2319 if original: 

2320 return original.name 

2321 

2322 return value 

2323 

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

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

2326 ): 

2327 """ 

2328 Custom grid value renderer for "notes" fields. 

2329 

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

2331 characters, it is returned as-is. 

2332 

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

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

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

2336 mouse hover. 

2337 

2338 To use this feature for your grid:: 

2339 

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

2341 

2342 # you can also override maxlen 

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

2344 """ 

2345 if value is None: 

2346 return None 

2347 

2348 if len(value) < maxlen: 

2349 return value 

2350 

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

2352 

2353 ############################## 

2354 # support methods 

2355 ############################## 

2356 

2357 def get_class_hierarchy(self, topfirst=True): 

2358 """ 

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

2360 class inherits. 

2361 

2362 This is a wrapper around 

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

2364 """ 

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

2366 

2367 def has_perm(self, name): 

2368 """ 

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

2370 

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

2372 ``name`` before passing it on to 

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

2374 

2375 For instance within the 

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

2377 result:: 

2378 

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

2380 

2381 self.has_perm('edit') 

2382 

2383 So this shortcut only applies to permissions defined for the 

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

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

2386 different prefix). 

2387 """ 

2388 permission_prefix = self.get_permission_prefix() 

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

2390 

2391 def has_any_perm(self, *names): 

2392 """ 

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

2394 permissions. 

2395 

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

2397 none do, returns ``False``. 

2398 """ 

2399 for name in names: 

2400 if self.has_perm(name): 

2401 return True 

2402 return False 

2403 

2404 def make_button( 

2405 self, 

2406 label, 

2407 variant=None, 

2408 primary=False, 

2409 url=None, 

2410 **kwargs, 

2411 ): 

2412 """ 

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

2414 

2415 :param label: Text label for the button. 

2416 

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

2418 for the button. Buefy and Oruga represent this differently 

2419 but this logic expects the Buefy format 

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

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

2422 terminology. 

2423 

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

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

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

2427 

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

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

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

2431 

2432 This is the preferred method where applicable, since it 

2433 avoids the Buefy vs. Oruga confusion, and the 

2434 implementation can change in the future. 

2435 

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

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

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

2439 

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

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

2442 attributes on the button tag. 

2443 

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

2445 reasons. 

2446 

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

2448 along the lines of: 

2449 

2450 .. code-block:: 

2451 

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

2453 icon-pack="fas" 

2454 icon-left="hand-pointer"> 

2455 Click Me 

2456 </b-button> 

2457 """ 

2458 btn_kw = kwargs 

2459 btn_kw.setdefault("c", label) 

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

2461 

2462 if "type" not in btn_kw: 

2463 if variant: 

2464 btn_kw["type"] = variant 

2465 elif primary: 

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

2467 

2468 if url: 

2469 btn_kw["href"] = url 

2470 

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

2472 

2473 if url: 

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

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

2476 # we patch that into place manually 

2477 button = str(button) 

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

2479 button = HTML.literal(button) 

2480 

2481 return button 

2482 

2483 def make_importer_component( 

2484 self, label=None, url=None, orientation="import", single=False, **kwargs 

2485 ): 

2486 """ 

2487 Generate HTML for a ``<wutta-model-importer>`` component. 

2488 

2489 This is really just a convenience but is meant to "centralize" 

2490 the logic for doing it. Will return something like: 

2491 

2492 .. code-block:: html 

2493 

2494 <wutta-model-importer label="Import from FooSystem" 

2495 url="/widgets/import-foosys" 

2496 orientation="import" /> 

2497 

2498 :param label: Label for the main button. 

2499 

2500 :param url: Action URL for the form submit. 

2501 

2502 :param orientation: Should be ``"import"`` (default) or 

2503 ``"export"``. As of writing this is only used to determine 

2504 which icon to give the button. 

2505 

2506 :param single: Whether the component is for a 

2507 importing/exporting a "single" record as opposed to the 

2508 "full" table. Defaults to the latter. This controls which 

2509 params are exposed for the user when they initiate a run. 

2510 

2511 :returns: Rendered HTML 

2512 """ 

2513 kwargs.setdefault(":single", "true" if single else "false") 

2514 return HTML.tag( 

2515 "wutta-model-importer", 

2516 orientation=orientation, 

2517 label=label, 

2518 url=url, 

2519 **kwargs, 

2520 ) 

2521 

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

2523 """ 

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

2525 when viewing the given object. 

2526 

2527 Default logic always returns empty list; subclass can override 

2528 as needed. 

2529 

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

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

2532 see/use. 

2533 

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

2535 

2536 def get_xref_buttons(self, product): 

2537 buttons = [] 

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

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

2540 id=product.external_id) 

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

2542 return buttons 

2543 """ 

2544 return [] 

2545 

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

2547 """ 

2548 Create and return a 

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

2550 given key. 

2551 

2552 This is normally done just before calling 

2553 :meth:`render_progress()`. 

2554 """ 

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

2556 

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

2558 """ 

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

2560 

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

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

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

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

2565 is redirected to the final destination. 

2566 

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

2568 

2569 :param progress: Progress indicator instance as returned by 

2570 :meth:`make_progress()`. 

2571 

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

2573 """ 

2574 template = template or "/progress.mako" 

2575 context = context or {} 

2576 context["progress"] = progress 

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

2578 

2579 def get_template_context(self, context): # pylint: disable=too-many-branches 

2580 """ 

2581 Collect the "complete" context for rendering the current 

2582 template. This is called from :meth:`render_to_response()`. 

2583 

2584 The default logic should be suitable for many cases but 

2585 subclass is expected to override if needed. 

2586 

2587 This one method is used for index, create, view, edit, etc. 

2588 The actual template file is not known at this point, so cannot 

2589 be inspected. But you can inspect :attr:`listing`, 

2590 :attr:`editing` (etc.) instead, or even inspect e.g. the 

2591 request :attr:`~pyramid.request.Request.matched_route` for the 

2592 "best" clue as to which template is involved. 

2593 

2594 This adds several things to the context, but the 

2595 caller-provided context values are always preferred. In all 

2596 cases these are added if not already present: 

2597 

2598 * ``master`` - reference to the current master class instance 

2599 * ``route_prefix`` - value from :meth:`get_route_prefix()` 

2600 * ``index_title`` - value from :meth:`get_index_title()` 

2601 * ``index_url`` - value from :meth:`get_index_url()` 

2602 * ``model_title`` - value from :meth:`get_model_title()` 

2603 * ``model_title_plural`` - value from :meth:`get_model_title_plural()` 

2604 * ``config_title`` - value from :meth:`get_config_title()` 

2605 

2606 If the context already contains an ``instance`` value, several 

2607 more things may be added: 

2608 

2609 * ``instance_title`` - value from :meth:`get_instance_title()` 

2610 * ``url_view_instance`` - URL to view the instance 

2611 * ``instance_editable`` - whether to show the Edit button 

2612 * ``url_edit_instance`` - URL to edit the instance 

2613 * ``instance_deletable`` - whether to show the Delete button 

2614 * ``url_delete_instance`` - URL to delete the instance 

2615 

2616 .. note:: 

2617 The templates generally expect an "instance" to be the 

2618 "current thing" e.g. in a CRUD scenario. This is of course 

2619 a :attr:`model_class` instance most of the time, but it can 

2620 also be a :attr:`row_model_class` instance. 

2621 

2622 This can be confusing, hence this note... I'm not sure 

2623 if/how the situation can be improved, since sometimes we 

2624 need to think of a row as a separate concept, but some CRUD 

2625 logic benefits from being more "agnostic" and not really 

2626 caring if it has a "parent" or "child" object to deal with. 

2627 

2628 :param context: Original context dict provided by view 

2629 callable. 

2630 

2631 :returns: Final context dict for the template. 

2632 """ 

2633 defaults = { 

2634 "master": self, 

2635 "route_prefix": self.get_route_prefix(), 

2636 "index_title": self.get_index_title(), 

2637 "index_url": self.get_index_url(), 

2638 "model_title": self.get_model_title(), 

2639 "model_title_plural": self.get_model_title_plural(), 

2640 "config_title": self.get_config_title(), 

2641 } 

2642 

2643 # merge defaults + caller-provided context 

2644 defaults.update(context) 

2645 context = defaults 

2646 

2647 # add crud flags, URLs etc. if we have an instance 

2648 if "instance" in context: 

2649 instance = context["instance"] 

2650 if is_row := self.has_rows and "parent_instance" in context: 

2651 parent = context["parent_instance"] 

2652 

2653 if "instance_title" not in context: 

2654 if is_row: 

2655 context["instance_title"] = self.get_row_instance_title(instance) 

2656 else: 

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

2658 

2659 if is_row: 

2660 context.setdefault( 

2661 "url_view_instance", 

2662 self.get_row_action_url("view_row", instance, parent=parent), 

2663 ) 

2664 else: 

2665 context.setdefault( 

2666 "url_view_instance", self.get_action_url("view", instance) 

2667 ) 

2668 

2669 if "instance_editable" not in context: 

2670 if is_row: 

2671 context["instance_editable"] = ( 

2672 self.rows_editable 

2673 and self.has_perm("edit_row") 

2674 and self.is_row_editable(instance) 

2675 ) 

2676 else: 

2677 context["instance_editable"] = ( 

2678 self.editable 

2679 and self.has_perm("edit") 

2680 and self.is_editable(instance) 

2681 ) 

2682 

2683 if context["instance_editable"]: 

2684 if is_row: 

2685 context.setdefault( 

2686 "url_edit_instance", 

2687 self.get_row_action_url("edit_row", instance, parent=parent), 

2688 ) 

2689 else: 

2690 context.setdefault( 

2691 "url_edit_instance", self.get_action_url("edit", instance) 

2692 ) 

2693 

2694 if "instance_deletable" not in context: 

2695 if is_row: 

2696 context["instance_deletable"] = ( 

2697 self.rows_deletable 

2698 and self.has_perm("delete_row") 

2699 and self.is_row_deletable(instance) 

2700 ) 

2701 else: 

2702 context["instance_deletable"] = ( 

2703 self.deletable 

2704 and self.has_perm("delete") 

2705 and self.is_deletable(instance) 

2706 ) 

2707 

2708 if context["instance_deletable"]: 

2709 if is_row: 

2710 context.setdefault( 

2711 "url_delete_instance", 

2712 self.get_row_action_url("delete_row", instance, parent=parent), 

2713 ) 

2714 else: 

2715 context.setdefault( 

2716 "url_delete_instance", self.get_action_url("delete", instance) 

2717 ) 

2718 

2719 return context 

2720 

2721 def render_to_response(self, template, context): 

2722 """ 

2723 Locate and render the appropriate template, and return the 

2724 final HTTP :term:`response`. 

2725 

2726 This first calls :meth:`get_template_context()` and then tries 

2727 to locate the template. 

2728 

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

2730 the template - e.g. ``"index"`` or ``"edit"``. This is fed 

2731 to :meth:`get_page_templates()` to obtain the list of 

2732 candidates. It then tries to render each until one succeeds. 

2733 

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

2735 will be attempted, e.g. if ``template`` is ``"edit"`` and 

2736 :attr:`template_prefix` is ``"/widgets"``: 

2737 

2738 * ``/widgets/edit.mako`` 

2739 * ``/master/edit.mako`` 

2740 

2741 Pyramid's 

2742 :func:`~pyramid:pyramid.renderers.render_to_response()` 

2743 handles the real work beyond that. 

2744 

2745 :param template: Base name for the template. 

2746 

2747 :param context: Initial template context from the view 

2748 callable. This will be fed to 

2749 :meth:`get_template_context()` to obtain the final/full 

2750 context. 

2751 

2752 :returns: :class:`~pyramid.response.Response` object for the 

2753 rendered template. 

2754 

2755 :raises IOError: If no template can be found. 

2756 """ 

2757 context = self.get_template_context(context) 

2758 

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

2760 page_templates = self.get_page_templates(template) 

2761 mako_path = page_templates[0] 

2762 try: 

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

2764 except IOError: 

2765 

2766 # failing that, try one or more fallback templates 

2767 for fallback in page_templates[1:]: 

2768 try: 

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

2770 except IOError: 

2771 pass 

2772 

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

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

2775 # let that error raise on up 

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

2777 

2778 def get_page_templates(self, template): 

2779 """ 

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

2781 render the current page. This is called by 

2782 :meth:`render_to_response()`. 

2783 

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

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

2786 entries becoming more generic. 

2787 

2788 In practice this method defines the first entry but calls 

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

2790 

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

2792 ``'view'``. 

2793 

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

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

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

2797 

2798 [ 

2799 '/widgets/view.mako', 

2800 '/master/view.mako', 

2801 ] 

2802 

2803 """ 

2804 template_prefix = self.get_template_prefix() 

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

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

2807 return page_templates 

2808 

2809 def get_fallback_templates(self, template): 

2810 """ 

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

2812 attempted for rendering the current page. See also 

2813 :meth:`get_page_templates()`. 

2814 

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

2816 ``'view'``. 

2817 

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

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

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

2821 

2822 ['/master/view.mako'] 

2823 """ 

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

2825 

2826 def get_index_title(self): 

2827 """ 

2828 Returns the main index title for the master view. 

2829 

2830 By default this returns the value from 

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

2832 needed. 

2833 """ 

2834 return self.get_model_title_plural() 

2835 

2836 def get_index_url(self, **kwargs): 

2837 """ 

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

2839 

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

2841 """ 

2842 if self.listable: 

2843 route_prefix = self.get_route_prefix() 

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

2845 return None 

2846 

2847 def set_labels(self, obj): 

2848 """ 

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

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

2851 

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

2853 :meth:`configure_form()`. 

2854 

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

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

2857 

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

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

2860 

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

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

2863 """ 

2864 labels = self.collect_labels() 

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

2866 obj.set_label(key, label) 

2867 

2868 def collect_labels(self): 

2869 """ 

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

2871 

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

2873 like so:: 

2874 

2875 from wuttaweb.views import MasterView 

2876 

2877 class WidgetView(MasterView): 

2878 

2879 labels = { 

2880 'id': "Widget ID", 

2881 'serial_no': "Serial Number", 

2882 } 

2883 

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

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

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

2887 wins. 

2888 

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

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

2891 

2892 :returns: Dict of all labels found. 

2893 """ 

2894 labels = {} 

2895 hierarchy = self.get_class_hierarchy() 

2896 for cls in hierarchy: 

2897 if hasattr(cls, "labels"): 

2898 labels.update(cls.labels) 

2899 return labels 

2900 

2901 def make_model_grid( 

2902 self, session=None, **kwargs 

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

2904 """ 

2905 Create the main grid for use with the :meth:`index()` view. 

2906 

2907 This may call several other methods to collect the grid 

2908 constructor kwargs etc.: 

2909 

2910 * :meth:`get_grid_key()` 

2911 * :meth:`get_grid_columns()` 

2912 * :meth:`get_grid_data()` 

2913 * :meth:`get_action_url_view()` 

2914 * :meth:`get_action_url_edit()` 

2915 * :meth:`get_action_url_delete()` 

2916 * :meth:`~wuttaweb.views.base.View.make_grid()` 

2917 * :meth:`configure_grid()` 

2918 

2919 Before returning the grid this also calls its 

2920 :meth:`~wuttaweb.grids.base.Grid.load_settings()` method. 

2921 

2922 :param session: Optional :term:`db session` for use with 

2923 :meth:`get_grid_data()`. If not specified then 

2924 :attr:`Session` will be assumed. 

2925 

2926 :param \\**kwargs: Override and/or additional kwargs for the 

2927 grid constructor. 

2928 

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

2930 """ 

2931 route_prefix = self.get_route_prefix() 

2932 

2933 if "key" not in kwargs: 

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

2935 

2936 if "model_class" not in kwargs: 

2937 model_class = self.get_model_class() 

2938 if model_class: 

2939 kwargs["model_class"] = model_class 

2940 

2941 if "columns" not in kwargs: 

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

2943 

2944 if "data" not in kwargs: 

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

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

2947 ) 

2948 

2949 if "actions" not in kwargs: 

2950 actions = [] 

2951 

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

2953 

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

2955 actions.append( 

2956 self.make_grid_action( 

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

2958 ) 

2959 ) 

2960 

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

2962 actions.append( 

2963 self.make_grid_action( 

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

2965 ) 

2966 ) 

2967 

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

2969 actions.append( 

2970 self.make_grid_action( 

2971 "delete", 

2972 icon="trash", 

2973 url=self.get_action_url_delete, 

2974 link_class="has-text-danger", 

2975 ) 

2976 ) 

2977 

2978 kwargs["actions"] = actions 

2979 

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

2981 

2982 if "tools" not in kwargs: 

2983 tools = [] 

2984 

2985 # show totals 

2986 if self.has_grid_totals: 

2987 button = self.make_button( 

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

2989 icon_left="calculator", 

2990 **{ 

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

2992 ":disabled": "gridTotalsFetching", 

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

2994 }, 

2995 ) 

2996 display = HTML.tag( 

2997 "div", 

2998 class_="control", 

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

3000 **{ 

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

3002 "v-html": "gridTotalsHTML", 

3003 }, 

3004 ) 

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

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

3007 

3008 # delete-bulk 

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

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

3011 

3012 # merge 

3013 if mergeable: 

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

3015 button = self.make_button( 

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

3017 primary=True, 

3018 native_type="submit", 

3019 icon_left="object-ungroup", 

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

3021 ) 

3022 csrf = render_csrf_token(self.request) 

3023 html = ( 

3024 tags.form( 

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

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

3027 ) 

3028 + csrf 

3029 + hidden 

3030 + button 

3031 + tags.end_form() 

3032 ) 

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

3034 

3035 kwargs["tools"] = tools 

3036 

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

3038 if hasattr(self, "grid_row_class"): 

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

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

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

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

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

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

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

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

3047 

3048 grid = self.make_grid(**kwargs) 

3049 self.configure_grid(grid) 

3050 grid.load_settings() 

3051 return grid 

3052 

3053 def get_grid_columns(self): 

3054 """ 

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

3056 :meth:`index()` view. 

3057 

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

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

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

3061 

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

3063 (try to) generate its own default list. 

3064 

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

3066 can override this method if needed. 

3067 

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

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

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

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

3072 remove or replace some of those within 

3073 :meth:`configure_grid()`. 

3074 """ 

3075 if hasattr(self, "grid_columns"): 

3076 return self.grid_columns 

3077 return None 

3078 

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

3080 self, columns=None, session=None 

3081 ): 

3082 """ 

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

3084 

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

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

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

3088 

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

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

3091 empty list. Subclass should override as needed. 

3092 """ 

3093 query = self.get_query(session=session) 

3094 if query: 

3095 return query 

3096 return [] 

3097 

3098 def get_query(self, session=None): 

3099 """ 

3100 Returns the main SQLAlchemy query object for the 

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

3102 :meth:`get_grid_data()`. 

3103 

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

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

3106 """ 

3107 model_class = self.get_model_class() 

3108 if model_class: 

3109 session = session or self.Session() 

3110 return session.query(model_class) 

3111 return None 

3112 

3113 def configure_grid(self, grid): 

3114 """ 

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

3116 

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

3118 

3119 There is minimal default logic here; subclass should override 

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

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

3122 based on request details etc. 

3123 """ 

3124 if "uuid" in grid.columns: 

3125 grid.columns.remove("uuid") 

3126 

3127 self.set_labels(grid) 

3128 

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

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

3131 # for key in self.get_model_key(): 

3132 # grid.set_link(key) 

3133 

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

3135 """ 

3136 This should return the appropriate model instance, based on 

3137 the ``matchdict`` of model keys. 

3138 

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

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

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

3142 (route/params). 

3143 

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

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

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

3147 

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

3149 obj = self.get_instance(matchdict=keys) 

3150 

3151 Although some models may have different, possibly composite 

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

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

3154 

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

3156 raise a 404 error, 

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

3158 

3159 Default implementation of this method should work okay for 

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

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

3162 may need to define. 

3163 

3164 .. warning:: 

3165 

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

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

3168 

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

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

3171 

3172 def get_instance(self, **kwargs): 

3173 

3174 # ..try to locate instance.. 

3175 obj = self.locate_instance_somehow() 

3176 

3177 if not obj: 

3178 

3179 # NB. THIS MAY NOT WORK AS EXPECTED 

3180 #return self.notfound() 

3181 

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

3183 raise self.notfound() 

3184 

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

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

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

3188 will kick in and control flow goes elsewhere. 

3189 """ 

3190 model_class = self.get_model_class() 

3191 if model_class: 

3192 session = session or self.Session() 

3193 matchdict = matchdict or self.request.matchdict 

3194 

3195 def filtr(query, model_key): 

3196 key = matchdict[model_key] 

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

3198 return query 

3199 

3200 query = session.query(model_class) 

3201 

3202 for key in self.get_model_key(): 

3203 query = filtr(query, key) 

3204 

3205 try: 

3206 return query.one() 

3207 except orm.exc.NoResultFound: 

3208 pass 

3209 

3210 raise self.notfound() 

3211 

3212 raise NotImplementedError( 

3213 "you must define get_instance() method " 

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

3215 ) 

3216 

3217 def get_instance_title(self, instance): 

3218 """ 

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

3220 in the page title when viewing etc. 

3221 

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

3223 subclass may override if needed. 

3224 """ 

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

3226 

3227 def get_action_route_kwargs(self, obj): 

3228 """ 

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

3230 

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

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

3233 

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

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

3236 the object. 

3237 

3238 :param obj: Model instance object. 

3239 

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

3241 """ 

3242 try: 

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

3244 except TypeError: 

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

3246 

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

3248 """ 

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

3250 

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

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

3253 

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

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

3256 returns the result. 

3257 

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

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

3260 

3261 :param obj: Model instance object. 

3262 

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

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

3265 """ 

3266 kw = self.get_action_route_kwargs(obj) 

3267 kw.update(kwargs) 

3268 route_prefix = self.get_route_prefix() 

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

3270 

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

3272 """ 

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

3274 

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

3276 represents the object's key/ID. 

3277 

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

3279 """ 

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

3281 

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

3283 """ 

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

3285 applicable. 

3286 

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

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

3289 

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

3291 this method will return ``None``. 

3292 

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

3294 """ 

3295 if self.is_editable(obj): 

3296 return self.get_action_url("edit", obj) 

3297 return None 

3298 

3299 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument 

3300 """ 

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

3302 applicable. 

3303 

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

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

3306 

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

3308 this method will return ``None``. 

3309 

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

3311 """ 

3312 if self.is_deletable(obj): 

3313 return self.get_action_url("delete", obj) 

3314 return None 

3315 

3316 def is_editable(self, obj): # pylint: disable=unused-argument 

3317 """ 

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

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

3320 

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

3322 if needed. 

3323 

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

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

3326 """ 

3327 return True 

3328 

3329 def is_deletable(self, obj): # pylint: disable=unused-argument 

3330 """ 

3331 Returns a boolean indicating whether "delete" should be 

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

3333 

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

3335 if needed. 

3336 

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

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

3339 """ 

3340 return True 

3341 

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

3343 """ 

3344 Make a form for the "model" represented by this subclass. 

3345 

3346 This method is normally called by all CRUD views: 

3347 

3348 * :meth:`create()` 

3349 * :meth:`view()` 

3350 * :meth:`edit()` 

3351 * :meth:`delete()` 

3352 

3353 The form need not have a ``model_instance``, as in the case of 

3354 :meth:`create()`. And it can be readonly as in the case of 

3355 :meth:`view()` and :meth:`delete()`. 

3356 

3357 If ``fields`` are not provided, :meth:`get_form_fields()` is 

3358 called. Usually a subclass will define :attr:`form_fields` 

3359 but it's only required if :attr:`model_class` is not set. 

3360 

3361 Then :meth:`configure_form()` is called, so subclass can go 

3362 crazy with that as needed. 

3363 

3364 :param model_instance: Model instance/record with which to 

3365 initialize the form data. Not needed for "create" forms. 

3366 

3367 :param fields: Optional fields list for the form. 

3368 

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

3370 """ 

3371 if "model_class" not in kwargs: 

3372 model_class = self.get_model_class() 

3373 if model_class: 

3374 kwargs["model_class"] = model_class 

3375 

3376 kwargs["model_instance"] = model_instance 

3377 

3378 if not fields: 

3379 fields = self.get_form_fields() 

3380 if fields: 

3381 kwargs["fields"] = fields 

3382 

3383 form = self.make_form(**kwargs) 

3384 self.configure_form(form) 

3385 return form 

3386 

3387 def get_form_fields(self): 

3388 """ 

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

3390 

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

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

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

3394 

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

3396 (try to) generate its own default list. 

3397 

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

3399 can override this method if needed. 

3400 

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

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

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

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

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

3406 """ 

3407 if hasattr(self, "form_fields"): 

3408 return self.form_fields 

3409 return None 

3410 

3411 def configure_form(self, form): 

3412 """ 

3413 Configure the given model form, as needed. 

3414 

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

3416 CRUD views (create, view, edit, delete, possibly others). 

3417 

3418 The default logic here does just one thing: when "editing" 

3419 (i.e. in :meth:`edit()` view) then all fields which are part 

3420 of the :attr:`model_key` will be marked via 

3421 :meth:`set_readonly()` so the user cannot change primary key 

3422 values for a record. 

3423 

3424 Subclass may override as needed. The ``form`` param will 

3425 already be "complete" and ready to use as-is, but this method 

3426 can further modify it based on request details etc. 

3427 """ 

3428 form.remove("uuid") 

3429 

3430 self.set_labels(form) 

3431 

3432 # mark key fields as readonly to prevent edit. see also 

3433 # related comments in the objectify() method 

3434 if self.editing: 

3435 for key in self.get_model_key(): 

3436 form.set_readonly(key) 

3437 

3438 def objectify(self, form): 

3439 """ 

3440 Must return a "model instance" object which reflects the 

3441 validated form data. 

3442 

3443 In simple cases this may just return the 

3444 :attr:`~wuttaweb.forms.base.Form.validated` data dict. 

3445 

3446 When dealing with SQLAlchemy models it would return a proper 

3447 mapped instance, creating it if necessary. 

3448 

3449 This is called by various other form-saving methods: 

3450 

3451 * :meth:`save_create_form()` 

3452 * :meth:`save_edit_form()` 

3453 * :meth:`create_row_save_form()` 

3454 

3455 See also :meth:`persist()`. 

3456 

3457 :param form: Reference to the *already validated* 

3458 :class:`~wuttaweb.forms.base.Form` object. See the form's 

3459 :attr:`~wuttaweb.forms.base.Form.validated` attribute for 

3460 the data. 

3461 """ 

3462 

3463 # ColanderAlchemy schema has an objectify() method which will 

3464 # return a populated model instance 

3465 schema = form.get_schema() 

3466 if hasattr(schema, "objectify"): 

3467 return schema.objectify(form.validated, context=form.model_instance) 

3468 

3469 # at this point we likely have no model class, so have to 

3470 # assume we're operating on a simple dict record. we (mostly) 

3471 # want to return that as-is, unless subclass overrides. 

3472 data = dict(form.validated) 

3473 

3474 # nb. we have a unique scenario when *editing* for a simple 

3475 # dict record (no model class). we mark the key fields as 

3476 # readonly in configure_form(), so they aren't part of the 

3477 # data here, but we need to add them back for sake of 

3478 # e.g. generating the 'view' route kwargs for redirect. 

3479 if self.editing: 

3480 obj = self.get_instance() 

3481 for key in self.get_model_key(): 

3482 if key not in data: 

3483 data[key] = obj[key] 

3484 

3485 return data 

3486 

3487 def persist(self, obj, session=None): 

3488 """ 

3489 If applicable, this method should persist ("save") the given 

3490 object's data (e.g. to DB), creating or updating it as needed. 

3491 

3492 This is part of the "submit form" workflow; ``obj`` should be 

3493 a model instance which already reflects the validated form 

3494 data. 

3495 

3496 Note that there is no default logic here, subclass must 

3497 override if needed. 

3498 

3499 :param obj: Model instance object as produced by 

3500 :meth:`objectify()`. 

3501 

3502 See also :meth:`save_create_form()` and 

3503 :meth:`save_edit_form()`, which call this method. 

3504 """ 

3505 model = self.app.model 

3506 model_class = self.get_model_class() 

3507 if model_class and issubclass(model_class, model.Base): 

3508 

3509 # add sqlalchemy model to session 

3510 session = session or self.Session() 

3511 session.add(obj) 

3512 

3513 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments 

3514 self, func, args, kwargs, onerror=None, session=None, progress=None 

3515 ): 

3516 """ 

3517 Generic method to invoke for thread operations. 

3518 

3519 :param func: Callable which performs the actual logic. This 

3520 will be wrapped with a try/except statement for error 

3521 handling. 

3522 

3523 :param args: Tuple of positional arguments to pass to the 

3524 ``func`` callable. 

3525 

3526 :param kwargs: Dict of keyword arguments to pass to the 

3527 ``func`` callable. 

3528 

3529 :param onerror: Optional callback to invoke if ``func`` raises 

3530 an error. It should not expect any arguments. 

3531 

3532 :param session: Optional :term:`db session` in effect. Note 

3533 that if supplied, it will be *committed* (or rolled back on 

3534 error) and *closed* by this method. If you need more 

3535 specialized handling, do not use this method (or don't 

3536 specify the ``session``). 

3537 

3538 :param progress: Optional progress factory. If supplied, this 

3539 is assumed to be a 

3540 :class:`~wuttaweb.progress.SessionProgress` instance, and 

3541 it will be updated per success or failure of ``func`` 

3542 invocation. 

3543 """ 

3544 try: 

3545 func(*args, **kwargs) 

3546 

3547 except Exception as error: # pylint: disable=broad-exception-caught 

3548 if session: 

3549 session.rollback() 

3550 if onerror: 

3551 onerror() 

3552 else: 

3553 log.warning("failed to invoke thread callable: %s", func, exc_info=True) 

3554 if progress: 

3555 progress.handle_error(error) 

3556 

3557 else: 

3558 if session: 

3559 session.commit() 

3560 if progress: 

3561 progress.handle_success() 

3562 

3563 finally: 

3564 if session: 

3565 session.close() 

3566 

3567 ############################## 

3568 # merge methods 

3569 ############################## 

3570 

3571 def merge(self): 

3572 """ 

3573 View for merging two records. 

3574 

3575 By default, this view is included only if :attr:`mergeable` is 

3576 true. It usually maps to a URL like ``/widgets/merge``. 

3577 

3578 A POST request must be used for this view; otherwise it will 

3579 redirect to the :meth:`index()` view. The POST data must 

3580 specify a ``uuids`` param string in 

3581 ``"removing_uuid,keeping_uuid"`` format. 

3582 

3583 The user is first shown a "diff" with the 

3584 removing/keeping/final data records, as simple preview. They 

3585 can swap removing vs. keeping if needed, and when satisfied 

3586 they can "execute" the merge. 

3587 

3588 See also related methods, used by this one: 

3589 

3590 * :meth:`merge_validate_and_execute()` 

3591 * :meth:`merge_get_data()` 

3592 * :meth:`merge_get_final_data()` 

3593 """ 

3594 if self.request.method != "POST": 

3595 return self.redirect(self.get_index_url()) 

3596 

3597 session = self.Session() 

3598 model_class = self.get_model_class() 

3599 

3600 # load records to be kept/removed 

3601 removing = keeping = None 

3602 uuids = self.request.POST.get("uuids", "").split(",") 

3603 if len(uuids) == 2: 

3604 uuid1, uuid2 = uuids 

3605 try: 

3606 uuid1 = UUID(uuid1) 

3607 uuid2 = UUID(uuid2) 

3608 except ValueError: 

3609 pass 

3610 else: 

3611 removing = session.get(model_class, uuid1) 

3612 keeping = session.get(model_class, uuid2) 

3613 

3614 # redirect to listing if record(s) not found 

3615 if not (removing and keeping): 

3616 raise self.redirect(self.get_index_url()) 

3617 

3618 # maybe execute merge 

3619 if self.request.POST.get("execute-merge") == "true": 

3620 if self.merge_validate_and_execute(removing, keeping): 

3621 return self.redirect(self.get_action_url("view", keeping)) 

3622 

3623 removing_data = self.merge_get_data(removing) 

3624 keeping_data = self.merge_get_data(keeping) 

3625 diff = MergeDiff( 

3626 self.config, 

3627 removing_data, 

3628 keeping_data, 

3629 self.merge_get_final_data(removing_data, keeping_data), 

3630 fields=self.merge_get_all_fields(), 

3631 ) 

3632 

3633 context = {"removing": removing, "keeping": keeping, "diff": diff} 

3634 return self.render_to_response("merge", context) 

3635 

3636 def merge_get_simple_fields(self): 

3637 """ 

3638 Return the list of "simple" fields for the merge. 

3639 

3640 These "simple" fields will not have any special handling for 

3641 the merge. In other words the "removing" record values will 

3642 be ignored and the "keeping" record values will remain in 

3643 place, without modification. 

3644 

3645 If the view class defines :attr:`merge_simple_fields`, that 

3646 list is returned as-is. Otherwise the list of columns from 

3647 :attr:`model_class` is returned. 

3648 

3649 :returns: List of simple field names. 

3650 """ 

3651 if self.merge_simple_fields: 

3652 return list(self.merge_simple_fields) 

3653 

3654 mapper = sa.inspect(self.get_model_class()) 

3655 fields = mapper.columns.keys() 

3656 return fields 

3657 

3658 def merge_get_additive_fields(self): 

3659 """ 

3660 Return the list of "additive" fields for the merge. 

3661 

3662 Values from the removing/keeping record will be conceptually 

3663 added together, for each of these fields. 

3664 

3665 If the view class defines :attr:`merge_additive_fields`, that 

3666 list is returned as-is. Otherwise an empty list is returned. 

3667 

3668 :returns: List of additive field names. 

3669 """ 

3670 if self.merge_additive_fields: 

3671 return list(self.merge_additive_fields) 

3672 return [] 

3673 

3674 def merge_get_coalesce_fields(self): 

3675 """ 

3676 Return the list of "coalesce" fields for the merge. 

3677 

3678 Values from the removing/keeping record will be conceptually 

3679 "coalesced" for each of these fields. 

3680 

3681 If the view class defines :attr:`merge_coalesce_fields`, that 

3682 list is returned as-is. Otherwise an empty list is returned. 

3683 

3684 :returns: List of coalesce field names. 

3685 """ 

3686 if self.merge_coalesce_fields: 

3687 return list(self.merge_coalesce_fields) 

3688 return [] 

3689 

3690 def merge_get_all_fields(self): 

3691 """ 

3692 Return the list of *all* fields for the merge. 

3693 

3694 This will call each of the following methods to collect all 

3695 field names, then it returns the full *sorted* list. 

3696 

3697 * :meth:`merge_get_additive_fields()` 

3698 * :meth:`merge_get_coalesce_fields()` 

3699 * :meth:`merge_get_simple_fields()` 

3700 

3701 :returns: Sorted list of all field names. 

3702 """ 

3703 fields = set() 

3704 fields.update(self.merge_get_simple_fields()) 

3705 fields.update(self.merge_get_additive_fields()) 

3706 fields.update(self.merge_get_coalesce_fields()) 

3707 return sorted(fields) 

3708 

3709 def merge_get_data(self, obj): 

3710 """ 

3711 Return a data dict for the given object, which will be either 

3712 the "removing" or "keeping" record for the merge. 

3713 

3714 By default this calls :meth:`merge_get_all_fields()` and then 

3715 for each field, calls ``getattr()`` on the object. Subclass 

3716 can override as needed for custom logic. 

3717 

3718 :param obj: Reference to model/record instance. 

3719 

3720 :returns: Data dict with all field values. 

3721 """ 

3722 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()} 

3723 

3724 def merge_get_final_data(self, removing, keeping): 

3725 """ 

3726 Return the "final" data dict for the merge. 

3727 

3728 The result will be identical to the "keeping" record, for all 

3729 "simple" fields. However the "additive" and "coalesce" fields 

3730 are handled specially per their nature, in which case those 

3731 final values may or may not match the "keeping" record. 

3732 

3733 :param removing: Data dict for the "removing" record. 

3734 

3735 :param keeping: Data dict for the "keeping" record. 

3736 

3737 :returns: Data dict with all "final" field values. 

3738 

3739 See also: 

3740 

3741 * :meth:`merge()` 

3742 * :meth:`merge_get_additive_fields()` 

3743 * :meth:`merge_get_coalesce_fields()` 

3744 """ 

3745 final = dict(keeping) 

3746 

3747 for field in self.merge_get_additive_fields(): 

3748 if isinstance(keeping[field], list): 

3749 final[field] = sorted(set(removing[field] + keeping[field])) 

3750 else: 

3751 final[field] = removing[field] + keeping[field] 

3752 

3753 for field in self.merge_get_coalesce_fields(): 

3754 if removing[field] is not None and keeping[field] is None: 

3755 final[field] = removing[field] 

3756 elif removing[field] and not keeping[field]: 

3757 final[field] = removing[field] 

3758 

3759 return final 

3760 

3761 def merge_validate_and_execute(self, removing, keeping): 

3762 """ 

3763 Validate and execute a merge for the two given records. It is 

3764 called from :meth:`merge()`. 

3765 

3766 This calls :meth:`merge_why_not()` and if that does not yield 

3767 a reason to prevent the merge, then calls 

3768 :meth:`merge_execute()`. 

3769 

3770 If there was a reason not to merge, or if an error occurs 

3771 during merge execution, a flash warning/error message is set 

3772 to notify the user what happened. 

3773 

3774 :param removing: Reference to the "removing" model instance/record. 

3775 

3776 :param keeping: Reference to the "keeping" model instance/record. 

3777 

3778 :returns: Boolean indicating whether merge execution completed 

3779 successfully. 

3780 """ 

3781 session = self.Session() 

3782 

3783 # validate the merge 

3784 if reason := self.merge_why_not(removing, keeping): 

3785 warning = HTML.tag( 

3786 "p", class_="block", c="Merge cannot proceed:" 

3787 ) + HTML.tag("p", class_="block", c=reason) 

3788 self.request.session.flash(warning, "warning") 

3789 return False 

3790 

3791 # execute the merge 

3792 removed_title = str(removing) 

3793 try: 

3794 self.merge_execute(removing, keeping) 

3795 session.flush() 

3796 except Exception as err: # pylint: disable=broad-exception-caught 

3797 session.rollback() 

3798 log.warning("merge failed", exc_info=True) 

3799 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag( 

3800 "p", class_="block", c=self.app.render_error(err) 

3801 ) 

3802 self.request.session.flash(warning, "error") 

3803 return False 

3804 

3805 self.request.session.flash(f"{removed_title} has been merged into {keeping}") 

3806 return True 

3807 

3808 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument 

3809 """ 

3810 Can return a "reason" why the two given records should not be merged. 

3811 

3812 This returns ``None`` by default, indicating the merge is 

3813 allowed. Subclass can override as needed for custom logic. 

3814 

3815 See also :meth:`merge_validate_and_execute()`. 

3816 

3817 :param removing: Reference to the "removing" model instance/record. 

3818 

3819 :param keeping: Reference to the "keeping" model instance/record. 

3820 

3821 :returns: Reason not to merge (as string), or ``None``. 

3822 """ 

3823 return None 

3824 

3825 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument 

3826 """ 

3827 Execute the actual merge for the two given objects. 

3828 

3829 Default logic simply deletes the "removing" record. Subclass 

3830 can override as needed for custom logic. 

3831 

3832 See also :meth:`merge_validate_and_execute()`. 

3833 

3834 :param removing: Reference to the "removing" model instance/record. 

3835 

3836 :param keeping: Reference to the "keeping" model instance/record. 

3837 """ 

3838 session = self.Session() 

3839 

3840 # nb. default "merge" does not update kept object! 

3841 session.delete(removing) 

3842 

3843 ############################## 

3844 # row methods 

3845 ############################## 

3846 

3847 def get_rows_title(self): 

3848 """ 

3849 Returns the display title for model **rows** grid, if 

3850 applicable/desired. Only relevant if :attr:`has_rows` is 

3851 true. 

3852 

3853 This will first check :attr:`rows_title` and use that if set; 

3854 otherwise it returns the value from 

3855 :meth:`get_row_model_title_plural()`. 

3856 

3857 :returns: String title for rows grid. 

3858 """ 

3859 if hasattr(self, "rows_title"): 

3860 return self.rows_title 

3861 return self.get_row_model_title_plural() 

3862 

3863 def get_row_parent(self, row): 

3864 """ 

3865 This must return the parent object for the given child row. 

3866 Only relevant if :attr:`has_rows` is true. 

3867 

3868 Default logic is not implemented; subclass must override. 

3869 """ 

3870 raise NotImplementedError 

3871 

3872 def make_row_model_grid(self, obj, **kwargs): 

3873 """ 

3874 Create and return a grid for an object's **rows** data, for 

3875 use in :meth:`view()`. Only applicable if :attr:`has_rows` is 

3876 true. 

3877 

3878 This may call several other methods to collect the grid 

3879 constructor kwargs etc.: 

3880 

3881 * :meth:`get_row_grid_key()` 

3882 * :meth:`get_row_grid_columns()` 

3883 * :meth:`get_row_grid_data()` 

3884 * :meth:`get_row_action_url_view()` 

3885 * :meth:`get_row_action_url_edit()` 

3886 * :meth:`get_row_action_url_delete()` 

3887 * :meth:`~wuttaweb.views.base.View.make_grid()` 

3888 * :meth:`configure_row_grid()` 

3889 

3890 Before returning the grid this also calls its 

3891 :meth:`~wuttaweb.grids.base.Grid.load_settings()` method. 

3892 

3893 :param obj: Current model instance for which rows data is 

3894 being displayed. 

3895 

3896 :param \\**kwargs: Override and/or additional kwargs for the 

3897 grid constructor. 

3898 

3899 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the 

3900 rows data. 

3901 """ 

3902 if "key" not in kwargs: 

3903 kwargs["key"] = self.get_row_grid_key() 

3904 

3905 if "model_class" not in kwargs: 

3906 model_class = self.get_row_model_class() 

3907 if model_class: 

3908 kwargs["model_class"] = model_class 

3909 

3910 if "columns" not in kwargs: 

3911 kwargs["columns"] = self.get_row_grid_columns() 

3912 

3913 if "data" not in kwargs: 

3914 kwargs["data"] = self.get_row_grid_data(obj) 

3915 

3916 kwargs.setdefault("filterable", self.rows_filterable) 

3917 kwargs.setdefault("filter_defaults", self.rows_filter_defaults) 

3918 kwargs.setdefault("sortable", self.rows_sortable) 

3919 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend) 

3920 kwargs.setdefault("sort_defaults", self.rows_sort_defaults) 

3921 kwargs.setdefault("paginated", self.rows_paginated) 

3922 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend) 

3923 

3924 if "actions" not in kwargs: 

3925 actions = [] 

3926 

3927 if self.rows_viewable and self.has_perm("view_row"): 

3928 actions.append( 

3929 self.make_grid_action( 

3930 "view", icon="eye", url=self.get_row_action_url_view 

3931 ) 

3932 ) 

3933 

3934 if self.rows_editable and self.has_perm("edit_row"): 

3935 actions.append( 

3936 self.make_grid_action( 

3937 "edit", icon="edit", url=self.get_row_action_url_edit 

3938 ) 

3939 ) 

3940 

3941 if self.rows_deletable and self.has_perm("delete_row"): 

3942 actions.append( 

3943 self.make_grid_action( 

3944 "delete", 

3945 icon="trash", 

3946 url=self.get_row_action_url_delete, 

3947 link_class="has-text-danger", 

3948 ) 

3949 ) 

3950 

3951 if actions: 

3952 kwargs["actions"] = actions 

3953 

3954 grid = self.make_grid(**kwargs) 

3955 self.configure_row_grid(grid) 

3956 grid.load_settings() 

3957 return grid 

3958 

3959 def get_row_grid_key(self): 

3960 """ 

3961 Returns the (presumably) unique key to be used for the 

3962 **rows** grid in :meth:`view()`. Only relevant if 

3963 :attr:`has_rows` is true. 

3964 

3965 This is called from :meth:`make_row_model_grid()`; in the 

3966 resulting grid, this becomes 

3967 :attr:`~wuttaweb.grids.base.Grid.key`. 

3968 

3969 Whereas you can define :attr:`grid_key` for the main grid, the 

3970 row grid key is always generated dynamically. This 

3971 incorporates the current record key (whose rows are in the 

3972 grid) so that the rows grid for each record is unique. 

3973 """ 

3974 parts = [self.get_grid_key()] 

3975 for key in self.get_model_key(): 

3976 parts.append(str(self.request.matchdict[key])) 

3977 return ".".join(parts) 

3978 

3979 def get_row_grid_columns(self): 

3980 """ 

3981 Returns the default list of column names for the **rows** 

3982 grid, for use in :meth:`view()`. Only relevant if 

3983 :attr:`has_rows` is true. 

3984 

3985 This is called by :meth:`make_row_model_grid()`; in the 

3986 resulting grid, this becomes 

3987 :attr:`~wuttaweb.grids.base.Grid.columns`. 

3988 

3989 This method may return ``None``, in which case the grid may 

3990 (try to) generate its own default list. 

3991 

3992 Subclass may define :attr:`row_grid_columns` for simple cases, 

3993 or can override this method if needed. 

3994 

3995 Also note that :meth:`configure_row_grid()` may be used to 

3996 further modify the final column set, regardless of what this 

3997 method returns. So a common pattern is to declare all 

3998 "supported" columns by setting :attr:`row_grid_columns` but 

3999 then optionally remove or replace some of those within 

4000 :meth:`configure_row_grid()`. 

4001 """ 

4002 if hasattr(self, "row_grid_columns"): 

4003 return self.row_grid_columns 

4004 return None 

4005 

4006 def get_row_grid_data(self, obj): 

4007 """ 

4008 Returns the data for the **rows** grid, for use in 

4009 :meth:`view()`. Only relevant if :attr:`has_rows` is true. 

4010 

4011 This is called by :meth:`make_row_model_grid()`; in the 

4012 resulting grid, this becomes 

4013 :attr:`~wuttaweb.grids.base.Grid.data`. 

4014 

4015 Default logic not implemented; subclass must define this. 

4016 """ 

4017 raise NotImplementedError 

4018 

4019 def configure_row_grid(self, grid): 

4020 """ 

4021 Configure the **rows** grid for use in :meth:`view()`. Only 

4022 relevant if :attr:`has_rows` is true. 

4023 

4024 This is called by :meth:`make_row_model_grid()`. 

4025 

4026 The default logic here (currently) does 2 things; subclass can 

4027 override as needed: 

4028 

4029 * remove the ``uuid`` column if present 

4030 * call :meth:`set_row_labels()` 

4031 """ 

4032 # we should never need this column 

4033 grid.remove("uuid") 

4034 

4035 # collect, apply labels 

4036 self.set_row_labels(grid) 

4037 

4038 def allow_create_row(self, obj): # pylint: disable=unused-argument 

4039 """ 

4040 This must return a boolean indicating whether the user is 

4041 allowed to create a new child row for the parent object. 

4042 

4043 This is called from :meth:`configure_row_grid()` - and 

4044 possibly other places? 

4045 

4046 Default logic checks :attr:`rows_creatable` and also ensures 

4047 the user has relevant ``create_row`` permission. 

4048 

4049 :param obj: Reference to the parent object. This will be an 

4050 instance of :attr:`model_class`. 

4051 

4052 :returns: Boolean, true if user can create new rows for the 

4053 object. 

4054 """ 

4055 if not self.rows_creatable: 

4056 return False 

4057 

4058 if not self.has_perm("create_row"): 

4059 return False 

4060 

4061 return True 

4062 

4063 def set_row_labels(self, obj): 

4064 """ 

4065 Set label overrides on a **row** form or grid, based on what 

4066 is defined by the view class and its parent class(es). 

4067 

4068 This is called automatically from 

4069 :meth:`configure_row_grid()` and 

4070 :meth:`configure_row_form()`. 

4071 

4072 This calls :meth:`collect_row_labels()` to find everything, 

4073 then it assigns the labels using one of (based on ``obj`` 

4074 type): 

4075 

4076 * :func:`wuttaweb.forms.base.Form.set_label()` 

4077 * :func:`wuttaweb.grids.base.Grid.set_label()` 

4078 

4079 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a 

4080 :class:`~wuttaweb.forms.base.Form` instance. 

4081 """ 

4082 labels = self.collect_row_labels() 

4083 for key, label in labels.items(): 

4084 obj.set_label(key, label) 

4085 

4086 def collect_row_labels(self): 

4087 """ 

4088 Collect all **row** labels defined within the view class 

4089 hierarchy. 

4090 

4091 This is called by :meth:`set_row_labels()`. 

4092 

4093 :returns: Dict of all labels found. 

4094 """ 

4095 labels = {} 

4096 hierarchy = self.get_class_hierarchy() 

4097 for cls in hierarchy: 

4098 if hasattr(cls, "row_labels"): 

4099 labels.update(cls.row_labels) 

4100 return labels 

4101 

4102 def get_row_action_route_kwargs(self, row, parent=None): 

4103 """ 

4104 Get a dict of route kwargs for the given **row** object. 

4105 

4106 This is called from :meth:`get_row_action_url()` and must return 

4107 kwargs suitable for use with ``request.route_url()``. 

4108 

4109 By default the returned dict will include model keys for both 

4110 the parent and row; field names for the latter being prefixed 

4111 by ``row_``. See also :meth:`get_row_url_prefix()`. 

4112 

4113 :param row: Row model instance. 

4114 

4115 :param parent: Optional parent model instance. If not 

4116 specified, will be fetched via :meth:`get_row_parent()`. 

4117 

4118 :returns: Dict of route kwargs for the row. 

4119 """ 

4120 obj = parent or self.get_row_parent(row) 

4121 obj_kw = self.get_action_route_kwargs(obj) 

4122 

4123 row_keys = self.get_row_model_key() 

4124 try: 

4125 row_kw = {f"row_{key}": row[key] for key in row_keys} 

4126 except TypeError: 

4127 row_kw = {f"row_{key}": getattr(row, key) for key in row_keys} 

4128 

4129 row_kw.update(obj_kw) 

4130 return row_kw 

4131 

4132 def get_row_action_url(self, action, row, parent=None, **kwargs): 

4133 """ 

4134 Retrieve the "view" action URL for the given row object. Only 

4135 relevant if :attr:`rows_viewable` is true. 

4136 

4137 Default logic always generates and returns the URL; subclass 

4138 may override to make that conditional based on the row data 

4139 etc. 

4140 

4141 :param row: Row model instance. 

4142 

4143 :param parent: Optional parent model instance. If not 

4144 specified, will be fetched via :meth:`get_row_parent()`. 

4145 

4146 :returns: View URL (as string) for the row, or ``None`` to 

4147 omit the row's View action link. 

4148 """ 

4149 kw = self.get_row_action_route_kwargs(row, parent=parent) 

4150 kw.update(kwargs) 

4151 route_prefix = self.get_route_prefix() 

4152 return self.request.route_url(f"{route_prefix}.{action}", **kw) 

4153 

4154 def get_row_action_url_view(self, row, i): # pylint: disable=unused-argument 

4155 """ 

4156 Retrieve the "view" action URL for the given row object. Only 

4157 relevant if :attr:`rows_viewable` is true. 

4158 

4159 Default logic always generates and returns the URL; subclass 

4160 may override to make that conditional based on the row data 

4161 etc. 

4162 

4163 :param row: Reference to a child row object. 

4164 

4165 :param i: 1-based sequence number for the row within its 

4166 (visible) grid. 

4167 

4168 :returns: View URL (as string) for the row, or ``None`` to 

4169 omit the row's View action link. 

4170 """ 

4171 return self.get_row_action_url("view_row", row) 

4172 

4173 def get_row_action_url_edit(self, row, i): # pylint: disable=unused-argument 

4174 """ 

4175 Retrieve the "edit" action URL for the given row. Only 

4176 relevant if :attr:`rows_editable` is true. 

4177 

4178 Default logic always generates and returns the URL; subclass 

4179 may override to make that conditional based on the row data 

4180 etc. 

4181 

4182 :param row: Row model instance. 

4183 

4184 :param i: 1-based sequence number for the row within its 

4185 (visible) grid. 

4186 

4187 :returns: Edit URL (as string) for the row, or ``None`` to 

4188 omit the row's Edit action link. 

4189 """ 

4190 if self.is_row_editable(row): 

4191 return self.get_row_action_url("edit_row", row) 

4192 return None 

4193 

4194 def get_row_action_url_delete(self, row, i): # pylint: disable=unused-argument 

4195 """ 

4196 Retrieve the "delete" action URL for the given row. Only 

4197 relevant if :attr:`rows_deletable` is true. 

4198 

4199 Default logic always generates and returns the URL; subclass 

4200 may override to make that conditional based on the row data 

4201 etc. 

4202 

4203 :param row: Row model instance. 

4204 

4205 :param i: 1-based sequence number for the row within its 

4206 (visible) grid. 

4207 

4208 :returns: Delete URL (as string) for the row, or ``None`` to 

4209 omit the row's Delete action link. 

4210 """ 

4211 if self.is_row_deletable(row): 

4212 return self.get_row_action_url("delete_row", row) 

4213 return None 

4214 

4215 def create_row(self): 

4216 """ 

4217 View to create a new "child row" record. 

4218 

4219 This usually corresponds to a URL like ``/widgets/XXX/new-row``. 

4220 

4221 By default, this view is included only if 

4222 :attr:`rows_creatable` is true. 

4223 

4224 The default "create row" view logic will show a form with 

4225 field widgets, allowing user to submit new values which are 

4226 then persisted to the DB (assuming typical SQLAlchemy model). 

4227 

4228 Subclass normally should not override this method, but rather 

4229 one of the related methods which are called (in)directly by 

4230 this one: 

4231 

4232 * :meth:`make_row_model_form()` 

4233 * :meth:`configure_row_form()` 

4234 * :meth:`create_row_save_form()` 

4235 * :meth:`redirect_after_create_row()` 

4236 """ 

4237 self.creating = True 

4238 parent = self.get_instance() 

4239 parent_url = self.get_action_url("view", parent) 

4240 

4241 form = self.make_row_model_form(cancel_url_fallback=parent_url) 

4242 if form.validate(): 

4243 result = self.create_row_save_form(form) 

4244 return self.redirect_after_create_row(result) 

4245 

4246 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

4247 parent_link = tags.link_to(self.get_instance_title(parent), parent_url) 

4248 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

4249 [index_link, parent_link] 

4250 ) 

4251 

4252 context = { 

4253 "form": form, 

4254 "index_title_rendered": index_title_rendered, 

4255 "row_model_title": self.get_row_model_title(), 

4256 } 

4257 return self.render_to_response("create_row", context) 

4258 

4259 def create_row_save_form(self, form): 

4260 """ 

4261 This method converts the validated form data to a row model 

4262 instance, and then saves the result to DB. It is called by 

4263 :meth:`create_row()`. 

4264 

4265 :returns: The resulting row model instance, as produced by 

4266 :meth:`objectify()`. 

4267 """ 

4268 row = self.objectify(form) 

4269 session = self.Session() 

4270 session.add(row) 

4271 session.flush() 

4272 return row 

4273 

4274 def redirect_after_create_row(self, row): 

4275 """ 

4276 Returns a redirect to the "view parent" page relative to the 

4277 given newly-created row. Subclass may override as needed. 

4278 

4279 This is called by :meth:`create_row()`. 

4280 """ 

4281 parent = self.get_row_parent(row) 

4282 return self.redirect(self.get_action_url("view", parent)) 

4283 

4284 def make_row_model_form(self, model_instance=None, **kwargs): 

4285 """ 

4286 Create and return a form for the row model. 

4287 

4288 This is called by :meth:`create_row()` and :meth:`view_row()`. 

4289 

4290 See also related methods, which are called by this one: 

4291 

4292 * :meth:`get_row_model_class()` 

4293 * :meth:`get_row_form_fields()` 

4294 * :meth:`~wuttaweb.views.base.View.make_form()` 

4295 * :meth:`configure_row_form()` 

4296 

4297 :param model_instance: Despite the generic name, this must be 

4298 the desired **row** model instance. Or can be ``None`` for 

4299 a creation form. 

4300 

4301 :returns: :class:`~wuttaweb.forms.base.Form` instance. 

4302 """ 

4303 if "model_class" not in kwargs: 

4304 model_class = self.get_row_model_class() 

4305 if model_class: 

4306 kwargs["model_class"] = model_class 

4307 

4308 kwargs["model_instance"] = model_instance 

4309 

4310 if not kwargs.get("fields"): 

4311 fields = self.get_row_form_fields() 

4312 if fields: 

4313 kwargs["fields"] = fields 

4314 

4315 form = self.make_form(**kwargs) 

4316 self.configure_row_form(form) 

4317 return form 

4318 

4319 def get_row_form_fields(self): 

4320 """ 

4321 Returns the initial list of field names for the row model 

4322 form. 

4323 

4324 This is called by :meth:`make_row_model_form()`; in the 

4325 resulting :class:`~wuttaweb.forms.base.Form` instance, this 

4326 becomes :attr:`~wuttaweb.forms.base.Form.fields`. 

4327 

4328 This method may return ``None``, in which case the form may 

4329 (try to) generate its own default list. 

4330 

4331 Subclass may define :attr:`row_form_fields` for simple cases, 

4332 or can override this method if needed. 

4333 

4334 Note that :meth:`configure_row_form()` may be used to further 

4335 modify the final field list, regardless of what this method 

4336 returns. So a common pattern is to declare all "supported" 

4337 fields by setting :attr:`row_form_fields` but then optionally 

4338 remove or replace some in :meth:`configure_row_form()`. 

4339 """ 

4340 if hasattr(self, "row_form_fields"): 

4341 return self.row_form_fields 

4342 return None 

4343 

4344 def configure_row_form(self, form): 

4345 """ 

4346 Configure the row model form. 

4347 

4348 This is called by :meth:`make_row_model_form()` - for multiple 

4349 CRUD views (create, view, edit, delete, possibly others). 

4350 

4351 The ``form`` param will already be "complete" and ready to use 

4352 as-is, but this method can further modify it based on request 

4353 details etc. 

4354 

4355 Subclass can override as needed, although be sure to invoke 

4356 this parent method via ``super()`` if so. 

4357 """ 

4358 form.remove("uuid") 

4359 self.set_row_labels(form) 

4360 

4361 def get_row_instance(self): 

4362 """ 

4363 Retrieve the row model instance for current view route. 

4364 

4365 This will typically query the underlying **rows** table using 

4366 keys from request 

4367 :attr:`~pyramid:pyramid.request.Request.matchdict`. 

4368 

4369 This should be automatic for a SQLAlchemy mapped class. For 

4370 other scenarios, subclass probably must override this method. 

4371 

4372 :returns: Current row model instance. 

4373 """ 

4374 if row_model_class := self.get_row_model_class(): 

4375 session = self.Session() 

4376 matchdict = self.request.matchdict 

4377 

4378 def filtr(query, row_model_key): 

4379 key = matchdict[f"row_{row_model_key}"] 

4380 return query.filter(getattr(row_model_class, row_model_key) == key) 

4381 

4382 query = session.query(row_model_class) 

4383 for key in self.get_row_model_key(): 

4384 query = filtr(query, key) 

4385 

4386 try: 

4387 return query.one() 

4388 except orm.exc.NoResultFound: 

4389 pass 

4390 

4391 raise self.notfound() 

4392 

4393 raise NotImplementedError( 

4394 f"must define get_row_instance() method for view class: {self.__class__}" 

4395 ) 

4396 

4397 def get_row_instance_title(self, row): 

4398 """ 

4399 Retrieve the human-friendly display title for a row model 

4400 instance. 

4401 

4402 :param row: Row model instance. 

4403 

4404 :returns: String title. 

4405 """ 

4406 return str(row) or "(no title)" 

4407 

4408 def view_row(self): 

4409 """ 

4410 View to "view" a **row** model record. 

4411 

4412 This usually corresponds to URL like ``/widgets/XXX/rows/YYY`` 

4413 

4414 By default, this route is included only if 

4415 :attr:`rows_viewable` is true. 

4416 

4417 The default logic here mostly just calls 

4418 :meth:`get_row_instance()` and :meth:`make_row_model_form()`, 

4419 then renders the ``/master/view_row.mako`` template. 

4420 """ 

4421 self.viewing = True 

4422 row = self.get_row_instance() 

4423 obj = self.get_row_parent(row) 

4424 

4425 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

4426 parent_title = self.get_instance_title(obj) 

4427 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj)) 

4428 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

4429 [index_link, parent_link] 

4430 ) 

4431 

4432 context = { 

4433 "index_title_rendered": index_title_rendered, 

4434 "parent_instance": obj, 

4435 "parent_instance_title": parent_title, 

4436 "instance": row, 

4437 "form": self.make_row_model_form(row, readonly=True), 

4438 } 

4439 

4440 return self.render_to_response("view_row", context) 

4441 

4442 def is_row_editable(self, row): # pylint: disable=unused-argument 

4443 """ 

4444 Check if "edit" should be allowed for the given row. Only 

4445 applicable if :attr:`rows_editable` is true. 

4446 

4447 By default this always return ``True``; subclass can override 

4448 to prevent edit for certain rows. 

4449 

4450 :param row: Row model instance. 

4451 

4452 :returns: Boolean, whether edit is allowed for the row. 

4453 """ 

4454 return True 

4455 

4456 def edit_row(self): 

4457 """ 

4458 View to "edit" a **row** model record. 

4459 

4460 This usually corresponds to URL like 

4461 ``/widgets/XXX/rows/YYY/edit`` 

4462 

4463 By default, this route is included only if 

4464 :attr:`rows_editable` is true. 

4465 

4466 The default logic here mostly just calls 

4467 :meth:`get_row_instance()` and :meth:`make_row_model_form()`, 

4468 then renders the ``/master/edit_row.mako`` template. 

4469 

4470 On successful update it calls :meth:`edit_row_save_form()` and 

4471 then :meth:`redirect_after_edit_row()`. 

4472 """ 

4473 self.editing = True 

4474 row = self.get_row_instance() 

4475 obj = self.get_row_parent(row) 

4476 

4477 form = self.make_row_model_form(row) 

4478 if form.validate(): 

4479 try: 

4480 result = self.edit_row_save_form(form) 

4481 except Exception as err: # pylint: disable=broad-exception-caught 

4482 log.warning("failed to save 'edit_row' form", exc_info=True) 

4483 self.request.session.flash(f"Edit failed: {err}", "error") 

4484 else: 

4485 return self.redirect_after_edit_row(result) 

4486 

4487 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

4488 parent_title = self.get_instance_title(obj) 

4489 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj)) 

4490 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

4491 [index_link, parent_link] 

4492 ) 

4493 

4494 context = { 

4495 "index_title_rendered": index_title_rendered, 

4496 "parent_instance": obj, 

4497 "parent_instance_title": parent_title, 

4498 "instance": row, 

4499 "form": form, 

4500 } 

4501 

4502 return self.render_to_response("edit_row", context) 

4503 

4504 def edit_row_save_form(self, form): 

4505 """ 

4506 Convert the form data to a row model instance, and save it to 

4507 the database. This is called by :meth:`edit_row()` when user 

4508 submits a valid edit form. 

4509 

4510 :returns: Final row model instance, as produced by 

4511 :meth:`objectify()`. 

4512 """ 

4513 row = self.objectify(form) 

4514 session = self.Session() 

4515 session.add(row) 

4516 session.flush() 

4517 return row 

4518 

4519 def redirect_after_edit_row(self, row): 

4520 """ 

4521 Returns a redirect to the "view parent" page relative to the 

4522 given row. Subclass may override as needed. 

4523 

4524 This is called by :meth:`edit_row()` after user submits a 

4525 valid edit form. 

4526 """ 

4527 parent = self.get_row_parent(row) 

4528 return self.redirect(self.get_action_url("view", parent)) 

4529 

4530 def is_row_deletable(self, row): # pylint: disable=unused-argument 

4531 """ 

4532 Check if "delete" should be allowed for the given row. Only 

4533 applicable if :attr:`rows_deletable` is true. 

4534 

4535 By default this always return ``True``; subclass can override 

4536 to prevent delete for certain rows. 

4537 

4538 :param row: Row model instance. 

4539 

4540 :returns: Boolean, whether delete is allowed for the row. 

4541 """ 

4542 return True 

4543 

4544 def delete_row(self): 

4545 """ 

4546 View to delete a **row** model instance. 

4547 

4548 This usually corresponds to URL like 

4549 ``/widgets/XXX/rows/YYY/delete`` 

4550 

4551 By default, this route is included only if 

4552 :attr:`rows_deletable` is true. 

4553 

4554 The default logic calls :meth:`make_delete_row_form()` and 

4555 shows that to the user. When they submit, it calls 

4556 :meth:`save_delete_row_form()` and then redirects. 

4557 

4558 Note that this gets the value from 

4559 :meth:`redirect_after_delete_row()` *before* saving the delete 

4560 form, since at that point we no longer would have a reliable 

4561 row reference. 

4562 """ 

4563 self.deleting = True 

4564 row = self.get_row_instance() 

4565 obj = self.get_row_parent(row) 

4566 

4567 if not self.is_row_deletable(row): 

4568 return self.redirect(self.get_row_action_url("view_row", row)) 

4569 

4570 form = self.make_delete_row_form(row) 

4571 

4572 # nb. validate() often returns empty dict here 

4573 if form.validate() is not False: 

4574 

4575 # nb. must figure out redirect before we trash the row 

4576 redirect = self.redirect_after_delete_row(row) 

4577 

4578 try: 

4579 self.save_delete_row_form(form) 

4580 except Exception as err: # pylint: disable=broad-exception-caught 

4581 log.warning("failed to save 'delete_row' form", exc_info=True) 

4582 self.request.session.flash(f"Delete failed: {err}", "error") 

4583 else: 

4584 return redirect 

4585 

4586 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

4587 parent_title = self.get_instance_title(obj) 

4588 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj)) 

4589 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

4590 [index_link, parent_link] 

4591 ) 

4592 

4593 context = { 

4594 "index_title_rendered": index_title_rendered, 

4595 "parent_instance": obj, 

4596 "parent_instance_title": parent_title, 

4597 "instance": row, 

4598 "form": form, 

4599 } 

4600 

4601 return self.render_to_response("delete_row", context) 

4602 

4603 def make_delete_row_form(self, row): 

4604 """ 

4605 Make the "delete" form for a row. This is called by 

4606 :meth:`delete_row()`. 

4607 

4608 Default logic calls :meth:`make_row_model_form()` but with a 

4609 twist: 

4610 

4611 The form proper is *not* readonly; this ensures the form has a 

4612 submit button etc. But then all fields in the form are 

4613 explicitly marked readonly. 

4614 

4615 :returns: :class:`~wuttaweb.forms.base.Form` instance 

4616 """ 

4617 # nb. this form proper is not readonly.. 

4618 form = self.make_row_model_form( 

4619 row, 

4620 cancel_url_fallback=self.get_row_action_url("view_row", row), 

4621 button_label_submit="DELETE Forever", 

4622 button_icon_submit="trash", 

4623 button_type_submit="is-danger", 

4624 ) 

4625 

4626 # ..but *all* fields are readonly 

4627 form.readonly_fields = set(form.fields) 

4628 return form 

4629 

4630 def save_delete_row_form(self, form): 

4631 """ 

4632 Save the "delete row" form. This is called by 

4633 :meth:`delete_row()`. 

4634 

4635 Default logic calls :meth:`delete_row_instance()`. Normally 

4636 subclass would override *that* for non-standard use cases, but 

4637 it could also/instead override *this* method. 

4638 

4639 This method is not expected to return a value. If it does, it 

4640 will be ignored. 

4641 """ 

4642 row = form.model_instance 

4643 self.delete_row_instance(row) 

4644 

4645 def delete_row_instance(self, row): 

4646 """ 

4647 Delete the given row. This is called by 

4648 :meth:`save_delete_row_form()`. 

4649 

4650 The default logic just calls 

4651 :meth:`~sqlalchemy:sqlalchemy.orm.Session.delete()` for the 

4652 row, on its session. 

4653 

4654 Subclass may need to override in order to properly remove the 

4655 association between row and its parent object etc. 

4656 

4657 :param row: Row model instance. 

4658 """ 

4659 session = self.app.get_session(row) 

4660 session.delete(row) 

4661 

4662 def redirect_after_delete_row(self, row): 

4663 """ 

4664 Returns a redirect to the "view parent" page relative to the 

4665 given row. Subclass may override as needed. 

4666 

4667 This is called by :meth:`delete_row()` *before* the row is 

4668 deleted. 

4669 

4670 :param row: Row model instance. 

4671 

4672 :returns: :class:`~pyramid.httpexceptions.HTTPFound` redirect 

4673 instance. 

4674 """ 

4675 parent = self.get_row_parent(row) 

4676 return self.redirect(self.get_action_url("view", parent)) 

4677 

4678 ############################## 

4679 # import/export methods 

4680 ############################## 

4681 

4682 def collect_importer_params(self): 

4683 """ 

4684 Collect import/export params from the request ``POST`` data, 

4685 coercing values as needed. 

4686 

4687 This is called from :meth:`do_import_full()` but can be used 

4688 for custom "single" import/export also. 

4689 

4690 The params from this are intended for use with the import 

4691 handler 

4692 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()` 

4693 method. As such, they are a "mixture" of params, some of 

4694 which are for the :term:`import handler` and some for the 

4695 :term:`importer`. The following should always be included: 

4696 

4697 * ``create`` - bool; corresponds to 

4698 :attr:`~wuttasync:wuttasync.importing.base.Importer.create` 

4699 * ``update`` - bool; corresponds to 

4700 :attr:`~wuttasync:wuttasync.importing.base.Importer.update` 

4701 * ``delete`` - bool; corresponds to 

4702 :attr:`~wuttasync:wuttasync.importing.base.Importer.delete` 

4703 * ``dry_run`` - bool; corresponds to 

4704 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.dry_run` 

4705 * ``warnings`` - bool; corresponds to 

4706 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.warnings` 

4707 

4708 However by default *all* params from ``POST`` are included; 

4709 the above are just "guaranteed" to be present. As of now 

4710 the only other param typically expected is: 

4711 

4712 * ``warnings_recipients`` - string or null; corresponds to 

4713 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.warnings_recipients` 

4714 

4715 :returns: Dict of import/export params. 

4716 """ 

4717 params = dict(self.request.POST) 

4718 

4719 for flag in ("create", "update", "delete", "dry_run", "warnings"): 

4720 params[flag] = self.config.parse_bool(params.get(flag, False)) 

4721 

4722 return params 

4723 

4724 def do_import_full(self, handler, models=None, **kwargs): 

4725 """ 

4726 This is a helper method to orchestrate a typical "full" 

4727 import/export run. See :meth:`add_importer_views()` for a 

4728 simple example. 

4729 

4730 In the typical use case, caller need only provide the handler, 

4731 and the value from :meth:`get_model_name()` will be the only 

4732 thing we ask the handler to process. But you can specify 

4733 explicit ``models`` if needed. 

4734 

4735 This calls :meth:`collect_importer_params()` and feeds the 

4736 result to 

4737 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()` 

4738 along with the desired model name(s). 

4739 

4740 If an error occurs, appropriate flash warning is set. 

4741 Otherwise :meth:`flash_import_summary()` is called to set a 

4742 more "complete" message. 

4743 

4744 :param handler: Instance of 

4745 :class:`~wuttasync:wuttasync.importing.handlers.ImportHandler`. 

4746 

4747 :param models: Optional model name(s) to import/export. If 

4748 not specified, the view model is assumed. 

4749 

4750 :param \\**kwargs: Remaining kwargs are added to the other 

4751 params, and passed along to ``process_data()``. 

4752 """ 

4753 # nb. we must "allow" GET method in general, but of course we 

4754 # don't want to process data for simple GET. if we did not 

4755 # allow GET in the routing though, the URL may be 

4756 # interpreted as a record UUID and that causes errors 

4757 # TODO: maybe we should raise an explicit error here? 

4758 if self.request.method != "POST": 

4759 return 

4760 

4761 if not models: 

4762 models = [self.get_model_name()] 

4763 if isinstance(models, str): 

4764 models = self.config.parse_list(models) 

4765 

4766 try: 

4767 params = self.collect_importer_params() 

4768 params.update(kwargs) 

4769 changes = handler.process_data(*models, **params) 

4770 self.flash_import_summary(handler, changes) 

4771 

4772 except Exception as err: # pylint: disable=broad-exception-caught 

4773 log.warning( 

4774 "process failed: %s (%s) for models: %s", 

4775 handler.get_title(), 

4776 handler.actioning, 

4777 models, 

4778 exc_info=True, 

4779 ) 

4780 self.request.session.flash( 

4781 f"Process failed: {self.app.render_error(err)}", "error" 

4782 ) 

4783 

4784 def flash_import_summary(self, handler, changes): 

4785 """ 

4786 Set a "detailed" flash message to summarize the result of an 

4787 import/export run. 

4788 

4789 The key data points included in this are: 

4790 

4791 * source, target (e.g. Foo → Poser) 

4792 * orientation (import vs. export) 

4793 * dry-run flag 

4794 * change highlights 

4795 

4796 This is called from :meth:`do_import_full()` but can be used 

4797 for custom "single" import/export also. 

4798 

4799 :param handler: 

4800 :class:`~wuttasync:wuttasync.importing.handlers.ImportHandler` 

4801 instance. 

4802 

4803 :param changes: Data changes from the import/export run, as 

4804 returned by 

4805 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()`. 

4806 """ 

4807 msg = [ 

4808 HTML.tag("span", c="Process complete for "), 

4809 HTML.tag( 

4810 "span", 

4811 c=f"{handler.get_title()} ({handler.actioning})", 

4812 class_="has-text-weight-bold", 

4813 ), 

4814 ] 

4815 if handler.dry_run: 

4816 msg.append(HTML.tag("span", c=" - DRY RUN", class_="is-italic")) 

4817 

4818 models = [] 

4819 for model_name, (created, updated, deleted) in changes.items(): 

4820 name = HTML.tag("span", class_="has-text-weight-bold", c=model_name) 

4821 counts = HTML.tag( 

4822 "span", 

4823 c="; ".join( 

4824 [ 

4825 f"{self.app.render_quantity(len(created))} created", 

4826 f"{self.app.render_quantity(len(updated))} updated", 

4827 f"{self.app.render_quantity(len(deleted))} deleted", 

4828 ] 

4829 ), 

4830 ) 

4831 models.append(HTML.tag("li", c=[name, HTML.literal(" - "), counts])) 

4832 

4833 if models: 

4834 summary = HTML.tag("ul", c=models) 

4835 else: 

4836 summary = HTML.tag("p", c="NO CHANGES", class_="is-italic") 

4837 

4838 self.request.session.flash(HTML.tag("p", c=msg) + HTML.tag("br") + summary) 

4839 

4840 ############################## 

4841 # class methods 

4842 ############################## 

4843 

4844 @classmethod 

4845 def get_model_class(cls): 

4846 """ 

4847 Returns the model class for the view (if defined). 

4848 

4849 A model class will *usually* be a SQLAlchemy mapped class, 

4850 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 

4851 

4852 There is no default value here, but a subclass may override by 

4853 assigning :attr:`model_class`. 

4854 

4855 Note that the model class is not *required* - however if you 

4856 do not set the :attr:`model_class`, then you *must* set the 

4857 :attr:`model_name`. 

4858 """ 

4859 return cls.model_class 

4860 

4861 @classmethod 

4862 def get_model_name(cls): 

4863 """ 

4864 Returns the model name for the view. 

4865 

4866 A model name should generally be in the format of a Python 

4867 class name, e.g. ``'WuttaWidget'``. (Note this is 

4868 *singular*, not plural.) 

4869 

4870 The default logic will call :meth:`get_model_class()` and 

4871 return that class name as-is. A subclass may override by 

4872 assigning :attr:`model_name`. 

4873 """ 

4874 if hasattr(cls, "model_name"): 

4875 return cls.model_name 

4876 

4877 return cls.get_model_class().__name__ 

4878 

4879 @classmethod 

4880 def get_model_name_normalized(cls): 

4881 """ 

4882 Returns the "normalized" model name for the view. 

4883 

4884 A normalized model name should generally be in the format of a 

4885 Python variable name, e.g. ``'wutta_widget'``. (Note this is 

4886 *singular*, not plural.) 

4887 

4888 The default logic will call :meth:`get_model_name()` and 

4889 simply lower-case the result. A subclass may override by 

4890 assigning :attr:`model_name_normalized`. 

4891 """ 

4892 if hasattr(cls, "model_name_normalized"): 

4893 return cls.model_name_normalized 

4894 

4895 return cls.get_model_name().lower() 

4896 

4897 @classmethod 

4898 def get_model_title(cls): 

4899 """ 

4900 Returns the "humanized" (singular) model title for the view. 

4901 

4902 The model title will be displayed to the user, so should have 

4903 proper grammar and capitalization, e.g. ``"Wutta Widget"``. 

4904 (Note this is *singular*, not plural.) 

4905 

4906 The default logic will call :meth:`get_model_name()` and use 

4907 the result as-is. A subclass may override by assigning 

4908 :attr:`model_title`. 

4909 """ 

4910 if hasattr(cls, "model_title"): 

4911 return cls.model_title 

4912 

4913 if model_class := cls.get_model_class(): 

4914 if hasattr(model_class, "__wutta_hint__"): 

4915 if model_title := model_class.__wutta_hint__.get("model_title"): 

4916 return model_title 

4917 

4918 return cls.get_model_name() 

4919 

4920 @classmethod 

4921 def get_model_title_plural(cls): 

4922 """ 

4923 Returns the "humanized" (plural) model title for the view. 

4924 

4925 The model title will be displayed to the user, so should have 

4926 proper grammar and capitalization, e.g. ``"Wutta Widgets"``. 

4927 (Note this is *plural*, not singular.) 

4928 

4929 The default logic will call :meth:`get_model_title()` and 

4930 simply add a ``'s'`` to the end. A subclass may override by 

4931 assigning :attr:`model_title_plural`. 

4932 """ 

4933 if hasattr(cls, "model_title_plural"): 

4934 return cls.model_title_plural 

4935 

4936 if model_class := cls.get_model_class(): 

4937 if hasattr(model_class, "__wutta_hint__"): 

4938 if model_title_plural := model_class.__wutta_hint__.get( 

4939 "model_title_plural" 

4940 ): 

4941 return model_title_plural 

4942 

4943 model_title = cls.get_model_title() 

4944 return f"{model_title}s" 

4945 

4946 @classmethod 

4947 def get_model_key(cls): 

4948 """ 

4949 Returns the "model key" for the master view. 

4950 

4951 This should return a tuple containing one or more "field 

4952 names" corresponding to the primary key for data records. 

4953 

4954 In the most simple/common scenario, where the master view 

4955 represents a Wutta-based SQLAlchemy model, the return value 

4956 for this method is: ``('uuid',)`` 

4957 

4958 Any class mapped via SQLAlchemy should be supported 

4959 automatically, the keys are determined from class inspection. 

4960 

4961 But there is no "sane" default for other scenarios, in which 

4962 case subclass should define :attr:`model_key`. If the model 

4963 key cannot be determined, raises ``AttributeError``. 

4964 

4965 :returns: Tuple of field names comprising the model key. 

4966 """ 

4967 if hasattr(cls, "model_key"): 

4968 keys = cls.model_key 

4969 if isinstance(keys, str): 

4970 keys = [keys] 

4971 return tuple(keys) 

4972 

4973 model_class = cls.get_model_class() 

4974 if model_class: 

4975 # nb. we want the primary key but must avoid column names 

4976 # in case mapped class uses different prop keys 

4977 inspector = sa.inspect(model_class) 

4978 keys = [col.name for col in inspector.primary_key] 

4979 return tuple( 

4980 prop.key 

4981 for prop in inspector.column_attrs 

4982 if all(col.name in keys for col in prop.columns) 

4983 ) 

4984 

4985 raise AttributeError(f"you must define model_key for view class: {cls}") 

4986 

4987 @classmethod 

4988 def get_route_prefix(cls): 

4989 """ 

4990 Returns the "route prefix" for the master view. This prefix 

4991 is used for all named routes defined by the view class. 

4992 

4993 For instance if route prefix is ``'widgets'`` then a view 

4994 might have these routes: 

4995 

4996 * ``'widgets'`` 

4997 * ``'widgets.create'`` 

4998 * ``'widgets.edit'`` 

4999 * ``'widgets.delete'`` 

5000 

5001 The default logic will call 

5002 :meth:`get_model_name_normalized()` and simply add an ``'s'`` 

5003 to the end, making it plural. A subclass may override by 

5004 assigning :attr:`route_prefix`. 

5005 """ 

5006 if hasattr(cls, "route_prefix"): 

5007 return cls.route_prefix 

5008 

5009 model_name = cls.get_model_name_normalized() 

5010 return f"{model_name}s" 

5011 

5012 @classmethod 

5013 def get_permission_prefix(cls): 

5014 """ 

5015 Returns the "permission prefix" for the master view. This 

5016 prefix is used for all permissions defined by the view class. 

5017 

5018 For instance if permission prefix is ``'widgets'`` then a view 

5019 might have these permissions: 

5020 

5021 * ``'widgets.list'`` 

5022 * ``'widgets.create'`` 

5023 * ``'widgets.edit'`` 

5024 * ``'widgets.delete'`` 

5025 

5026 The default logic will call :meth:`get_route_prefix()` and use 

5027 that value as-is. A subclass may override by assigning 

5028 :attr:`permission_prefix`. 

5029 """ 

5030 if hasattr(cls, "permission_prefix"): 

5031 return cls.permission_prefix 

5032 

5033 return cls.get_route_prefix() 

5034 

5035 @classmethod 

5036 def get_url_prefix(cls): 

5037 """ 

5038 Returns the "URL prefix" for the master view. This prefix is 

5039 used for all URLs defined by the view class. 

5040 

5041 Using the same example as in :meth:`get_route_prefix()`, the 

5042 URL prefix would be ``'/widgets'`` and the view would have 

5043 defined routes for these URLs: 

5044 

5045 * ``/widgets/`` 

5046 * ``/widgets/new`` 

5047 * ``/widgets/XXX/edit`` 

5048 * ``/widgets/XXX/delete`` 

5049 

5050 The default logic will call :meth:`get_route_prefix()` and 

5051 simply add a ``'/'`` to the beginning. A subclass may 

5052 override by assigning :attr:`url_prefix`. 

5053 """ 

5054 if hasattr(cls, "url_prefix"): 

5055 return cls.url_prefix 

5056 

5057 route_prefix = cls.get_route_prefix() 

5058 return f"/{route_prefix}" 

5059 

5060 @classmethod 

5061 def get_instance_url_prefix(cls): 

5062 """ 

5063 Generate the URL prefix specific to an instance for this model 

5064 view. This will include model key param placeholders; it 

5065 winds up looking like: 

5066 

5067 * ``/widgets/{uuid}`` 

5068 * ``/resources/{foo}|{bar}|{baz}`` 

5069 

5070 The former being the most simple/common, and the latter 

5071 showing what a "composite" model key looks like, with pipe 

5072 symbols separating the key parts. 

5073 """ 

5074 prefix = cls.get_url_prefix() + "/" 

5075 for i, key in enumerate(cls.get_model_key()): 

5076 if i: 

5077 prefix += "|" 

5078 prefix += f"{{{key}}}" 

5079 return prefix 

5080 

5081 @classmethod 

5082 def get_template_prefix(cls): 

5083 """ 

5084 Returns the "template prefix" for the master view. This 

5085 prefix is used to guess which template path to render for a 

5086 given view. 

5087 

5088 Using the same example as in :meth:`get_url_prefix()`, the 

5089 template prefix would also be ``'/widgets'`` and the templates 

5090 assumed for those routes would be: 

5091 

5092 * ``/widgets/index.mako`` 

5093 * ``/widgets/create.mako`` 

5094 * ``/widgets/edit.mako`` 

5095 * ``/widgets/delete.mako`` 

5096 

5097 The default logic will call :meth:`get_url_prefix()` and 

5098 return that value as-is. A subclass may override by assigning 

5099 :attr:`template_prefix`. 

5100 """ 

5101 if hasattr(cls, "template_prefix"): 

5102 return cls.template_prefix 

5103 

5104 return cls.get_url_prefix() 

5105 

5106 @classmethod 

5107 def get_grid_key(cls): 

5108 """ 

5109 Returns the (presumably) unique key to be used for the primary 

5110 grid in the :meth:`index()` view. This key may also be used 

5111 as the basis (key prefix) for secondary grids. 

5112 

5113 This is called from :meth:`make_model_grid()`; in the 

5114 resulting :class:`~wuttaweb.grids.base.Grid` instance, this 

5115 becomes :attr:`~wuttaweb.grids.base.Grid.key`. 

5116 

5117 The default logic for this method will call 

5118 :meth:`get_route_prefix()` and return that value as-is. A 

5119 subclass may override by assigning :attr:`grid_key`. 

5120 """ 

5121 if hasattr(cls, "grid_key"): 

5122 return cls.grid_key 

5123 

5124 return cls.get_route_prefix() 

5125 

5126 @classmethod 

5127 def get_config_title(cls): 

5128 """ 

5129 Returns the "config title" for the view/model. 

5130 

5131 The config title is used for page title in the 

5132 :meth:`configure()` view, as well as links to it. It is 

5133 usually plural, e.g. ``"Wutta Widgets"`` in which case that 

5134 winds up being displayed in the web app as: **Configure Wutta 

5135 Widgets** 

5136 

5137 The default logic will call :meth:`get_model_title_plural()` 

5138 and return that as-is. A subclass may override by assigning 

5139 :attr:`config_title`. 

5140 """ 

5141 if hasattr(cls, "config_title"): 

5142 return cls.config_title 

5143 

5144 return cls.get_model_title_plural() 

5145 

5146 @classmethod 

5147 def get_row_model_class(cls): 

5148 """ 

5149 Returns the "child row" model class for the view. Only 

5150 relevant if :attr:`has_rows` is true. 

5151 

5152 Default logic returns the :attr:`row_model_class` reference. 

5153 

5154 :returns: Mapped class, or ``None`` 

5155 """ 

5156 return cls.row_model_class 

5157 

5158 @classmethod 

5159 def get_row_model_key(cls): 

5160 """ 

5161 Returns the "row model key" for the view. Contrast with 

5162 :meth:`get_model_key()` which is for the main (parent) model. 

5163 

5164 This should return a tuple containing one or more "field 

5165 names" corresponding to the primary key for **row** data 

5166 records. 

5167 

5168 In the most simple/common scenario, where the 

5169 :attr:`row_model_class` represents a Wutta-based SQLAlchemy 

5170 model, the return value is: ``("uuid",)`` 

5171 

5172 Any class mapped via SQLAlchemy should be supported 

5173 automatically; the keys are determined from class inspection. 

5174 

5175 But there is no "sane" default for other scenarios, in which 

5176 case subclass should define :attr:`row_model_key`. If the row 

5177 model key cannot be determined, raises ``AttributeError``. 

5178 

5179 :returns: Tuple of field names comprising the row model key. 

5180 """ 

5181 if hasattr(cls, "row_model_key"): 

5182 keys = cls.row_model_key 

5183 if isinstance(keys, str): 

5184 keys = [keys] 

5185 return tuple(keys) 

5186 

5187 row_class = cls.get_row_model_class() 

5188 if row_class: 

5189 # nb. we want the primary key but must avoid column names 

5190 # in case mapped class uses different prop keys 

5191 inspector = sa.inspect(row_class) 

5192 keys = [col.name for col in inspector.primary_key] 

5193 return tuple( 

5194 prop.key 

5195 for prop in inspector.column_attrs 

5196 if all(col.name in keys for col in prop.columns) 

5197 ) 

5198 

5199 raise AttributeError(f"you must define row_model_key for view class: {cls}") 

5200 

5201 @classmethod 

5202 def get_row_model_name(cls): 

5203 """ 

5204 Returns the row model name for the view. 

5205 

5206 A model name should generally be in the format of a Python 

5207 class name, e.g. ``'BatchRow'``. (Note this is *singular*, 

5208 not plural.) 

5209 

5210 The default logic will call :meth:`get_row_model_class()` and 

5211 return that class name as-is. Subclass may override by 

5212 assigning :attr:`row_model_name`. 

5213 """ 

5214 if hasattr(cls, "row_model_name"): 

5215 return cls.row_model_name 

5216 

5217 return cls.get_row_model_class().__name__ 

5218 

5219 @classmethod 

5220 def get_row_model_title(cls): 

5221 """ 

5222 Returns the "humanized" (singular) title for the row model. 

5223 

5224 The model title will be displayed to the user, so should have 

5225 proper grammar and capitalization, e.g. ``"Batch Row"``. 

5226 (Note this is *singular*, not plural.) 

5227 

5228 The default logic will call :meth:`get_row_model_name()` and 

5229 use the result as-is. Subclass may override by assigning 

5230 :attr:`row_model_title`. 

5231 

5232 See also :meth:`get_row_model_title_plural()`. 

5233 """ 

5234 if hasattr(cls, "row_model_title"): 

5235 return cls.row_model_title 

5236 

5237 if model_class := cls.get_row_model_class(): 

5238 if hasattr(model_class, "__wutta_hint__"): 

5239 if model_title := model_class.__wutta_hint__.get("model_title"): 

5240 return model_title 

5241 

5242 return cls.get_row_model_name() 

5243 

5244 @classmethod 

5245 def get_row_model_title_plural(cls): 

5246 """ 

5247 Returns the "humanized" (plural) title for the row model. 

5248 

5249 The model title will be displayed to the user, so should have 

5250 proper grammar and capitalization, e.g. ``"Batch Rows"``. 

5251 (Note this is *plural*, not singular.) 

5252 

5253 The default logic will call :meth:`get_row_model_title()` and 

5254 simply add a ``'s'`` to the end. Subclass may override by 

5255 assigning :attr:`row_model_title_plural`. 

5256 """ 

5257 if hasattr(cls, "row_model_title_plural"): 

5258 return cls.row_model_title_plural 

5259 

5260 if model_class := cls.get_row_model_class(): 

5261 if hasattr(model_class, "__wutta_hint__"): 

5262 if model_title_plural := model_class.__wutta_hint__.get( 

5263 "model_title_plural" 

5264 ): 

5265 return model_title_plural 

5266 

5267 row_model_title = cls.get_row_model_title() 

5268 return f"{row_model_title}s" 

5269 

5270 @classmethod 

5271 def get_row_url_prefix(cls): 

5272 """ 

5273 Generate the URL prefix specific to a **row** model instance. 

5274 

5275 For practical reasons, this URL will embed the key params for 

5276 both the *parent* model as well as the row. Also, the latter 

5277 will be prefixed with ``row_``, for example: 

5278 

5279 * ``/widgets/{uuid}/rows/{row_uuid}`` 

5280 * ``/widgets/{id}|{name}/rows/{row_id}|{row_name}`` 

5281 

5282 The first example being the most simple/common, and the latter 

5283 showing what a "composite" model key looks like (for both 

5284 parent and row), with pipe symbols separating the key parts. 

5285 """ 

5286 prefix = cls.get_instance_url_prefix() + "/rows/" 

5287 for i, key in enumerate(cls.get_row_model_key()): 

5288 if i: 

5289 prefix += "|" 

5290 prefix += f"{{row_{key}}}" 

5291 return prefix 

5292 

5293 ############################## 

5294 # configuration 

5295 ############################## 

5296 

5297 @classmethod 

5298 def defaults(cls, config): 

5299 """ 

5300 Provide default Pyramid configuration for a master view. 

5301 

5302 This is generally called from within the module's 

5303 ``includeme()`` function, e.g.:: 

5304 

5305 from wuttaweb.views import MasterView 

5306 

5307 class WidgetView(MasterView): 

5308 model_name = 'Widget' 

5309 

5310 def includeme(config): 

5311 WidgetView.defaults(config) 

5312 

5313 :param config: Reference to the app's 

5314 :class:`pyramid:pyramid.config.Configurator` instance. 

5315 """ 

5316 cls._defaults(config) 

5317 

5318 @classmethod 

5319 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches 

5320 wutta_config = config.registry.settings.get("wutta_config") 

5321 app = wutta_config.get_app() 

5322 

5323 route_prefix = cls.get_route_prefix() 

5324 permission_prefix = cls.get_permission_prefix() 

5325 url_prefix = cls.get_url_prefix() 

5326 model_title = cls.get_model_title() 

5327 model_title_plural = cls.get_model_title_plural() 

5328 

5329 # add to master view registry 

5330 config.add_wutta_master_view(cls) 

5331 

5332 # permission group 

5333 config.add_wutta_permission_group( 

5334 permission_prefix, model_title_plural, overwrite=False 

5335 ) 

5336 

5337 # index 

5338 if cls.listable: 

5339 config.add_route(route_prefix, f"{url_prefix}/") 

5340 config.add_view( 

5341 cls, 

5342 attr="index", 

5343 route_name=route_prefix, 

5344 permission=f"{permission_prefix}.list", 

5345 ) 

5346 config.add_wutta_permission( 

5347 permission_prefix, 

5348 f"{permission_prefix}.list", 

5349 f"Browse / search {model_title_plural}", 

5350 ) 

5351 

5352 # grid totals 

5353 if cls.has_grid_totals: 

5354 config.add_route( 

5355 f"{route_prefix}.fetch_grid_totals", 

5356 f"{url_prefix}/fetch-grid-totals", 

5357 request_method="GET", 

5358 ) 

5359 config.add_view( 

5360 cls, 

5361 attr="fetch_grid_totals", 

5362 route_name=f"{route_prefix}.fetch_grid_totals", 

5363 permission=f"{permission_prefix}.list", 

5364 renderer="json", 

5365 ) 

5366 

5367 # create 

5368 if cls.creatable: 

5369 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new") 

5370 config.add_view( 

5371 cls, 

5372 attr="create", 

5373 route_name=f"{route_prefix}.create", 

5374 permission=f"{permission_prefix}.create", 

5375 ) 

5376 config.add_wutta_permission( 

5377 permission_prefix, 

5378 f"{permission_prefix}.create", 

5379 f"Create new {model_title}", 

5380 ) 

5381 

5382 # edit 

5383 if cls.editable: 

5384 instance_url_prefix = cls.get_instance_url_prefix() 

5385 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit") 

5386 config.add_view( 

5387 cls, 

5388 attr="edit", 

5389 route_name=f"{route_prefix}.edit", 

5390 permission=f"{permission_prefix}.edit", 

5391 ) 

5392 config.add_wutta_permission( 

5393 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}" 

5394 ) 

5395 

5396 # delete 

5397 if cls.deletable: 

5398 instance_url_prefix = cls.get_instance_url_prefix() 

5399 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete") 

5400 config.add_view( 

5401 cls, 

5402 attr="delete", 

5403 route_name=f"{route_prefix}.delete", 

5404 permission=f"{permission_prefix}.delete", 

5405 ) 

5406 config.add_wutta_permission( 

5407 permission_prefix, 

5408 f"{permission_prefix}.delete", 

5409 f"Delete {model_title}", 

5410 ) 

5411 

5412 # bulk delete 

5413 if cls.deletable_bulk: 

5414 config.add_route( 

5415 f"{route_prefix}.delete_bulk", 

5416 f"{url_prefix}/delete-bulk", 

5417 request_method="POST", 

5418 ) 

5419 config.add_view( 

5420 cls, 

5421 attr="delete_bulk", 

5422 route_name=f"{route_prefix}.delete_bulk", 

5423 permission=f"{permission_prefix}.delete_bulk", 

5424 ) 

5425 config.add_wutta_permission( 

5426 permission_prefix, 

5427 f"{permission_prefix}.delete_bulk", 

5428 f"Delete {model_title_plural} in bulk", 

5429 ) 

5430 

5431 # merge 

5432 if cls.mergeable: 

5433 config.add_wutta_permission( 

5434 permission_prefix, 

5435 f"{permission_prefix}.merge", 

5436 f"Merge 2 {model_title_plural}", 

5437 ) 

5438 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge") 

5439 config.add_view( 

5440 cls, 

5441 attr="merge", 

5442 route_name=f"{route_prefix}.merge", 

5443 permission=f"{permission_prefix}.merge", 

5444 ) 

5445 

5446 # autocomplete 

5447 if cls.has_autocomplete: 

5448 config.add_route( 

5449 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete" 

5450 ) 

5451 config.add_view( 

5452 cls, 

5453 attr="autocomplete", 

5454 route_name=f"{route_prefix}.autocomplete", 

5455 renderer="json", 

5456 permission=f"{route_prefix}.list", 

5457 ) 

5458 

5459 # download 

5460 if cls.downloadable: 

5461 instance_url_prefix = cls.get_instance_url_prefix() 

5462 config.add_route( 

5463 f"{route_prefix}.download", f"{instance_url_prefix}/download" 

5464 ) 

5465 config.add_view( 

5466 cls, 

5467 attr="download", 

5468 route_name=f"{route_prefix}.download", 

5469 permission=f"{permission_prefix}.download", 

5470 ) 

5471 config.add_wutta_permission( 

5472 permission_prefix, 

5473 f"{permission_prefix}.download", 

5474 f"Download file(s) for {model_title}", 

5475 ) 

5476 

5477 # execute 

5478 if cls.executable: 

5479 instance_url_prefix = cls.get_instance_url_prefix() 

5480 config.add_route( 

5481 f"{route_prefix}.execute", 

5482 f"{instance_url_prefix}/execute", 

5483 request_method="POST", 

5484 ) 

5485 config.add_view( 

5486 cls, 

5487 attr="execute", 

5488 route_name=f"{route_prefix}.execute", 

5489 permission=f"{permission_prefix}.execute", 

5490 ) 

5491 config.add_wutta_permission( 

5492 permission_prefix, 

5493 f"{permission_prefix}.execute", 

5494 f"Execute {model_title}", 

5495 ) 

5496 

5497 # configure 

5498 if cls.configurable: 

5499 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure") 

5500 config.add_view( 

5501 cls, 

5502 attr="configure", 

5503 route_name=f"{route_prefix}.configure", 

5504 permission=f"{permission_prefix}.configure", 

5505 ) 

5506 config.add_wutta_permission( 

5507 permission_prefix, 

5508 f"{permission_prefix}.configure", 

5509 f"Configure {model_title_plural}", 

5510 ) 

5511 

5512 # view 

5513 # nb. always register this one last, so it does not take 

5514 # priority over model-wide action routes, e.g. delete_bulk 

5515 if cls.viewable: 

5516 instance_url_prefix = cls.get_instance_url_prefix() 

5517 config.add_route(f"{route_prefix}.view", instance_url_prefix) 

5518 config.add_view( 

5519 cls, 

5520 attr="view", 

5521 route_name=f"{route_prefix}.view", 

5522 permission=f"{permission_prefix}.view", 

5523 ) 

5524 config.add_wutta_permission( 

5525 permission_prefix, f"{permission_prefix}.view", f"View {model_title}" 

5526 ) 

5527 

5528 # version history 

5529 if cls.is_versioned() and app.continuum_is_enabled(): 

5530 instance_url_prefix = cls.get_instance_url_prefix() 

5531 config.add_wutta_permission( 

5532 permission_prefix, 

5533 f"{permission_prefix}.versions", 

5534 f"View change history for {model_title}", 

5535 ) 

5536 config.add_route( 

5537 f"{route_prefix}.versions", f"{instance_url_prefix}/history/" 

5538 ) 

5539 config.add_view( 

5540 cls, 

5541 attr="view_versions", 

5542 route_name=f"{route_prefix}.versions", 

5543 permission=f"{permission_prefix}.versions", 

5544 ) 

5545 config.add_route( 

5546 f"{route_prefix}.version", f"{instance_url_prefix}/history/{{txnid}}" 

5547 ) 

5548 config.add_view( 

5549 cls, 

5550 attr="view_version", 

5551 route_name=f"{route_prefix}.version", 

5552 permission=f"{permission_prefix}.versions", 

5553 ) 

5554 

5555 ############################## 

5556 # row-specific routes 

5557 ############################## 

5558 

5559 if cls.has_rows: 

5560 row_model_title = cls.get_row_model_title() 

5561 

5562 # create row 

5563 if cls.rows_creatable: 

5564 config.add_wutta_permission( 

5565 permission_prefix, 

5566 f"{permission_prefix}.create_row", 

5567 f"Create new {row_model_title} for {model_title}", 

5568 ) 

5569 config.add_route( 

5570 f"{route_prefix}.create_row", f"{instance_url_prefix}/rows/new" 

5571 ) 

5572 config.add_view( 

5573 cls, 

5574 attr="create_row", 

5575 route_name=f"{route_prefix}.create_row", 

5576 permission=f"{permission_prefix}.create_row", 

5577 ) 

5578 

5579 # view row 

5580 if cls.rows_viewable: 

5581 row_url_prefix = cls.get_row_url_prefix() 

5582 config.add_wutta_permission( 

5583 permission_prefix, 

5584 f"{permission_prefix}.view_row", 

5585 f"View {row_model_title} for {model_title}", 

5586 ) 

5587 config.add_route(f"{route_prefix}.view_row", row_url_prefix) 

5588 config.add_view( 

5589 cls, 

5590 attr="view_row", 

5591 route_name=f"{route_prefix}.view_row", 

5592 permission=f"{permission_prefix}.view_row", 

5593 ) 

5594 

5595 # edit row 

5596 if cls.rows_editable: 

5597 row_url_prefix = cls.get_row_url_prefix() 

5598 config.add_wutta_permission( 

5599 permission_prefix, 

5600 f"{permission_prefix}.edit_row", 

5601 f"Edit {row_model_title} for {model_title}", 

5602 ) 

5603 config.add_route(f"{route_prefix}.edit_row", f"{row_url_prefix}/edit") 

5604 config.add_view( 

5605 cls, 

5606 attr="edit_row", 

5607 route_name=f"{route_prefix}.edit_row", 

5608 permission=f"{permission_prefix}.edit_row", 

5609 ) 

5610 

5611 # delete row 

5612 if cls.rows_deletable: 

5613 row_url_prefix = cls.get_row_url_prefix() 

5614 config.add_wutta_permission( 

5615 permission_prefix, 

5616 f"{permission_prefix}.delete_row", 

5617 f"Delete {row_model_title} from {model_title}", 

5618 ) 

5619 config.add_route( 

5620 f"{route_prefix}.delete_row", f"{row_url_prefix}/delete" 

5621 ) 

5622 config.add_view( 

5623 cls, 

5624 attr="delete_row", 

5625 route_name=f"{route_prefix}.delete_row", 

5626 permission=f"{permission_prefix}.delete_row", 

5627 ) 

5628 

5629 @classmethod 

5630 def add_importer_views(cls, config, action, handler=None, permission_title=None): 

5631 """ 

5632 Add route/view config for a given "action" identifier. Both 

5633 "full" and "single" flavors are supported. 

5634 

5635 The ``action`` is a string, usually like ``import_foo`` or 

5636 ``export_foo``, which is used to generate the route names etc. 

5637 

5638 The class must define corresponding view callable method(s) 

5639 separately; this only does up-front config in Pyramid. For 

5640 instance:: 

5641 

5642 from wuttaweb.views import MasterView 

5643 

5644 class WidgetView(MasterView): 

5645 

5646 # nb. maps to e.g. /widgets/import-foo 

5647 def import_foo_full(self): 

5648 # nb. import all widgets from foo 

5649 handler = self.app.get_import_handler("import.to_poser.from_foo") 

5650 self.do_import_full(handler) 

5651 return self.redirect(self.get_index_url()) 

5652 

5653 # nb. maps to e.g. /widgets/XXX/import-foo 

5654 def import_foo_single(self): 

5655 # TODO: single-record import requires custom logic 

5656 raise NotImplementedError 

5657 

5658 @classmethod 

5659 def defaults(cls, config): 

5660 cls._defaults(config) 

5661 cls.add_importer_views(config, "import_foo", 

5662 permission_title="Import Widgets from Foo") 

5663 

5664 See also the :meth:`do_import_full()` method. 

5665 

5666 :param config: Reference to the app's 

5667 :class:`pyramid:pyramid.config.Configurator` instance. 

5668 

5669 :param action: Name to use as basis for permission, route, 

5670 view callable method names. 

5671 

5672 :param handler: If you provide an import handler instance, it 

5673 can be used to generate the permission display title. 

5674 

5675 :param permission_title: Override for the permission display 

5676 title. 

5677 

5678 Note that you "must" provide either ``handler`` or 

5679 ``permission_title``, otherwise the default generated 

5680 permission title will be pretty bad. 

5681 """ 

5682 route_prefix = cls.get_route_prefix() 

5683 permission_prefix = cls.get_permission_prefix() 

5684 url_prefix = cls.get_url_prefix() 

5685 instance_url_prefix = cls.get_instance_url_prefix() 

5686 model_title_plural = cls.get_model_title_plural() 

5687 slug = action.replace("_", "-") 

5688 

5689 # permission 

5690 if not permission_title: 

5691 if handler and handler.orientation.value == "export": 

5692 permission_title = ( 

5693 f"Export {model_title_plural} to {handler.get_target_title()}" 

5694 ) 

5695 elif handler: # import 

5696 permission_title = ( 

5697 f"Import {model_title_plural} from {handler.get_source_title()}" 

5698 ) 

5699 else: 

5700 # TODO: seriously what else can we do here 

5701 permission_title = "TODO: import/export something?" 

5702 config.add_wutta_permission( 

5703 permission_prefix, f"{permission_prefix}.{action}", permission_title 

5704 ) 

5705 

5706 # import full 

5707 config.add_route( 

5708 f"{route_prefix}.{action}_full", 

5709 f"{url_prefix}/{slug}", 

5710 ) 

5711 config.add_view( 

5712 cls, 

5713 attr=f"{action}_full", 

5714 route_name=f"{route_prefix}.{action}_full", 

5715 permission=f"{permission_prefix}.{action}", 

5716 ) 

5717 

5718 # import single 

5719 config.add_route( 

5720 f"{route_prefix}.{action}_single", 

5721 f"{instance_url_prefix}/{slug}", 

5722 request_method="POST", 

5723 ) 

5724 config.add_view( 

5725 cls, 

5726 attr=f"{action}_single", 

5727 route_name=f"{route_prefix}.{action}_single", 

5728 permission=f"{permission_prefix}.{action}", 

5729 )