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

1106 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-20 21:14 -0500

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

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

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

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

12# later version. 

13# 

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

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

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

17# more details. 

18# 

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

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

21# 

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

23""" 

24Base Logic for Master Views 

25""" 

26# pylint: disable=too-many-lines 

27 

28import logging 

29import os 

30import threading 

31import warnings 

32from uuid import UUID 

33 

34import sqlalchemy as sa 

35from sqlalchemy import orm 

36 

37from pyramid.renderers import render_to_response 

38from webhelpers2.html import HTML, tags 

39 

40from wuttjamaican.util import get_class_hierarchy 

41from wuttaweb.views.base import View 

42from wuttaweb.util import get_form_data, render_csrf_token 

43from wuttaweb.db import Session 

44from wuttaweb.progress import SessionProgress 

45from wuttaweb.diffs import MergeDiff, VersionDiff 

46 

47 

48log = logging.getLogger(__name__) 

49 

50 

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

52 """ 

53 Base class for "master" views. 

54 

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

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

57 data record. 

58 

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

60 define :attr:`model_class`:: 

61 

62 from wuttaweb.views import MasterView 

63 from wuttjamaican.db.model import Person 

64 

65 class MyPersonView(MasterView): 

66 model_class = Person 

67 

68 def includeme(config): 

69 MyPersonView.defaults(config) 

70 

71 .. note:: 

72 

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

74 explicitly defined in a subclass. There are corresponding 

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

76 these attributes directly. 

77 

78 .. attribute:: model_class 

79 

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

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

82 mapped class, 

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

84 

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

86 :meth:`get_model_class()`. 

87 

88 .. attribute:: model_name 

89 

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

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

92 

93 Code should not access this directly but instead call 

94 :meth:`get_model_name()`. 

95 

96 .. attribute:: model_name_normalized 

97 

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

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

100 

101 Code should not access this directly but instead call 

102 :meth:`get_model_name_normalized()`. 

103 

104 .. attribute:: model_title 

105 

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

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

108 

109 Code should not access this directly but instead call 

110 :meth:`get_model_title()`. 

111 

112 .. attribute:: model_title_plural 

113 

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

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

116 

117 Code should not access this directly but instead call 

118 :meth:`get_model_title_plural()`. 

119 

120 .. attribute:: model_key 

121 

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

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

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

125 

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

127 model key can be determined automatically. 

128 

129 Code should not access this directly but instead call 

130 :meth:`get_model_key()`. 

131 

132 .. attribute:: grid_key 

133 

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

135 

136 Code should not access this directly but instead call 

137 :meth:`get_grid_key()`. 

138 

139 .. attribute:: config_title 

140 

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

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

143 

144 Code should not access this directly but instead call 

145 :meth:`get_config_title()`. 

146 

147 .. attribute:: route_prefix 

148 

149 Optional override for the view's route prefix, 

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

151 

152 Code should not access this directly but instead call 

153 :meth:`get_route_prefix()`. 

154 

155 .. attribute:: permission_prefix 

156 

157 Optional override for the view's permission prefix, 

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

159 

160 Code should not access this directly but instead call 

161 :meth:`get_permission_prefix()`. 

162 

163 .. attribute:: url_prefix 

164 

165 Optional override for the view's URL prefix, 

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

167 

168 Code should not access this directly but instead call 

169 :meth:`get_url_prefix()`. 

170 

171 .. attribute:: template_prefix 

172 

173 Optional override for the view's template prefix, 

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

175 

176 Code should not access this directly but instead call 

177 :meth:`get_template_prefix()`. 

178 

179 .. attribute:: listable 

180 

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

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

183 ``True``. 

184 

185 .. attribute:: has_grid 

186 

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

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

189 

190 .. attribute:: grid_columns 

191 

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

193 

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

195 

196 .. attribute:: checkable 

197 

198 Boolean indicating whether the grid should expose per-row 

199 checkboxes. This is passed along to set 

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

201 

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

203 

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

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

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

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

208 

209 For more info see 

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

211 

212 .. attribute:: filterable 

213 

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

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

216 

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

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

219 

220 .. attribute:: filter_defaults 

221 

222 Optional dict of default filter state. 

223 

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

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

226 

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

228 

229 .. attribute:: sortable 

230 

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

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

233 

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

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

236 

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

238 

239 .. attribute:: sort_on_backend 

240 

241 Boolean indicating whether the grid data for the 

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

243 is ``True``. 

244 

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

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

247 

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

249 

250 .. attribute:: sort_defaults 

251 

252 Optional list of default sorting info. Applicable for both 

253 frontend and backend sorting. 

254 

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

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

257 

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

259 

260 .. attribute:: paginated 

261 

262 Boolean indicating whether the grid data for the 

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

264 

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

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

267 

268 .. attribute:: paginate_on_backend 

269 

270 Boolean indicating whether the grid data for the 

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

272 Default is ``True``. 

273 

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

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

276 

277 .. attribute:: creatable 

278 

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

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

281 ``True``. 

282 

283 .. attribute:: viewable 

284 

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

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

287 ``True``. 

288 

289 .. attribute:: editable 

290 

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

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

293 ``True``. 

294 

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

296 

297 .. attribute:: deletable 

298 

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

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

301 ``True``. 

302 

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

304 

305 .. attribute:: deletable_bulk 

306 

307 Boolean indicating whether the view model supports "bulk 

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

309 Default value is ``False``. 

310 

311 See also :attr:`deletable_bulk_quick`. 

312 

313 .. attribute:: deletable_bulk_quick 

314 

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

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

317 should happen *synchronously* with no progress indicator. 

318 

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

320 shown while the bulk deletion is performed. 

321 

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

323 

324 .. attribute:: form_fields 

325 

326 List of fields for the model form. 

327 

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

329 

330 .. attribute:: has_autocomplete 

331 

332 Boolean indicating whether the view model supports 

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

334 view. Default is ``False``. 

335 

336 .. attribute:: downloadable 

337 

338 Boolean indicating whether the view model supports 

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

340 Default is ``False``. 

341 

342 .. attribute:: executable 

343 

344 Boolean indicating whether the view model supports "executing" 

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

346 ``False``. 

347 

348 .. attribute:: configurable 

349 

350 Boolean indicating whether the master view supports 

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

352 Default value is ``False``. 

353 

354 .. attribute:: version_grid_columns 

355 

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

357 

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

359 

360 .. attribute:: mergeable 

361 

362 Boolean indicating whether the view model supports "merging two 

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

364 value is ``False``. 

365 

366 .. attribute:: merge_additive_fields 

367 

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

369 nature when merging two records. Only relevant if 

370 :attr:`mergeable` is true. 

371 

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

373 

374 .. attribute:: merge_coalesce_fields 

375 

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

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

378 is true. 

379 

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

381 

382 .. attribute:: merge_simple_fields 

383 

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

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

386 

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

388 

389 **ROW FEATURES** 

390 

391 .. attribute:: has_rows 

392 

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

394 displayed when viewing model records. For instance when 

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

396 as well as its row data. 

397 

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

399 on then many other things kick in. 

400 

401 See also :attr:`row_model_class`. 

402 

403 .. attribute:: row_model_class 

404 

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

406 

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

408 

409 View logic should not access this directly but instead call 

410 :meth:`get_row_model_class()`. 

411 

412 .. attribute:: row_model_name 

413 

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

415 e.g. ``'WuttaWidget'``. 

416 

417 Code should not access this directly but instead call 

418 :meth:`get_row_model_name()`. 

419 

420 .. attribute:: row_model_title 

421 

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

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

424 

425 Code should not access this directly but instead call 

426 :meth:`get_row_model_title()`. 

427 

428 .. attribute:: row_model_title_plural 

429 

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

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

432 

433 Code should not access this directly but instead call 

434 :meth:`get_row_model_title_plural()`. 

435 

436 .. attribute:: rows_title 

437 

438 Display title for the rows grid. 

439 

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

441 :meth:`get_rows_title()`. 

442 

443 .. attribute:: row_grid_columns 

444 

445 List of columns for the row grid. 

446 

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

448 

449 .. attribute:: rows_viewable 

450 

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

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

453 is ``False``. 

454 

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

456 :meth:`get_row_action_url_view()`. 

457 

458 .. note:: 

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

460 to be configured as well. 

461 

462 .. attribute:: row_form_fields 

463 

464 List of fields for the row model form. 

465 

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

467 

468 .. attribute:: rows_creatable 

469 

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

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

472 Default value is ``False``. 

473 """ 

474 

475 ############################## 

476 # attributes 

477 ############################## 

478 

479 model_class = None 

480 

481 # features 

482 listable = True 

483 has_grid = True 

484 checkable = False 

485 filterable = True 

486 filter_defaults = None 

487 sortable = True 

488 sort_on_backend = True 

489 sort_defaults = None 

490 paginated = True 

491 paginate_on_backend = True 

492 creatable = True 

493 viewable = True 

494 editable = True 

495 deletable = True 

496 deletable_bulk = False 

497 deletable_bulk_quick = False 

498 has_autocomplete = False 

499 downloadable = False 

500 executable = False 

501 execute_progress_template = None 

502 configurable = False 

503 

504 # merging 

505 mergeable = False 

506 merge_additive_fields = None 

507 merge_coalesce_fields = None 

508 merge_simple_fields = None 

509 

510 # row features 

511 has_rows = False 

512 row_model_class = None 

513 rows_filterable = True 

514 rows_filter_defaults = None 

515 rows_sortable = True 

516 rows_sort_on_backend = True 

517 rows_sort_defaults = None 

518 rows_paginated = True 

519 rows_paginate_on_backend = True 

520 rows_viewable = False 

521 rows_creatable = False 

522 

523 # current action 

524 listing = False 

525 creating = False 

526 viewing = False 

527 editing = False 

528 deleting = False 

529 executing = False 

530 configuring = False 

531 

532 # default DB session 

533 Session = Session 

534 

535 ############################## 

536 # index methods 

537 ############################## 

538 

539 def index(self): 

540 """ 

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

542 

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

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

545 e.g. ``/widgets/``. 

546 

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

548 true. 

549 

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

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

552 

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

554 

555 * :meth:`make_model_grid()` 

556 """ 

557 self.listing = True 

558 

559 context = { 

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

561 } 

562 

563 if self.has_grid: 

564 grid = self.make_model_grid() 

565 

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

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

568 

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

570 context = grid.get_vue_context() 

571 if grid.paginated and grid.paginate_on_backend: 

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

573 return self.json_response(context) 

574 

575 # full, not partial 

576 

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

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

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

580 

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

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

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

584 

585 context["grid"] = grid 

586 

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

588 

589 ############################## 

590 # create methods 

591 ############################## 

592 

593 def create(self): 

594 """ 

595 View to "create" a new model record. 

596 

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

598 

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

600 is true. 

601 

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

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

604 :meth:`save_create_form()` and then 

605 :meth:`redirect_after_create()`. 

606 """ 

607 self.creating = True 

608 form = self.make_create_form() 

609 

610 if form.validate(): 

611 session = self.Session() 

612 try: 

613 result = self.save_create_form(form) 

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

615 session.flush() 

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

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

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

619 else: 

620 return self.redirect_after_create(result) 

621 

622 context = {"form": form} 

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

624 

625 def make_create_form(self): 

626 """ 

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

628 :meth:`create()`. 

629 

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

631 

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

633 """ 

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

635 

636 def save_create_form(self, form): 

637 """ 

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

639 

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

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

642 non-standard use cases. 

643 

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

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

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

647 :meth:`redirect_after_create()`. 

648 

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

650 """ 

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

652 warnings.warn( 

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

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

655 DeprecationWarning, 

656 ) 

657 return self.create_save_form(form) 

658 

659 obj = self.objectify(form) 

660 self.persist(obj) 

661 return obj 

662 

663 def redirect_after_create(self, result): 

664 """ 

665 Must return a redirect, following successful save of the 

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

667 

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

669 record. 

670 

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

672 """ 

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

674 

675 ############################## 

676 # view methods 

677 ############################## 

678 

679 def view(self): 

680 """ 

681 View to "view" a model record. 

682 

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

684 

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

686 true. 

687 

688 The default logic here is as follows: 

689 

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

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

692 

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

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

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

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

697 sorting and pagination etc.) 

698 

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

700 is rendered. 

701 """ 

702 self.viewing = True 

703 obj = self.get_instance() 

704 context = {"instance": obj} 

705 

706 if self.has_rows: 

707 

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

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

710 grid = self.make_row_model_grid(obj) 

711 

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

713 # redirect so the query string gets cleared out 

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

715 

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

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

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

719 

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

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

722 context = grid.get_vue_context() 

723 if grid.paginated and grid.paginate_on_backend: 

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

725 return self.json_response(context) 

726 

727 context["rows_grid"] = grid 

728 

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

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

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

732 

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

734 """ 

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

736 :meth:`view()`. 

737 

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

739 

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

741 """ 

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

743 

744 ############################## 

745 # edit methods 

746 ############################## 

747 

748 def edit(self): 

749 """ 

750 View to "edit" a model record. 

751 

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

753 

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

755 true. 

756 

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

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

759 :meth:`save_edit_form()` and then 

760 :meth:`redirect_after_edit()`. 

761 """ 

762 self.editing = True 

763 instance = self.get_instance() 

764 form = self.make_edit_form(instance) 

765 

766 if form.validate(): 

767 try: 

768 result = self.save_edit_form(form) 

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

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

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

772 else: 

773 return self.redirect_after_edit(result) 

774 

775 context = { 

776 "instance": instance, 

777 "form": form, 

778 } 

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

780 

781 def make_edit_form(self, obj): 

782 """ 

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

784 :meth:`edit()`. 

785 

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

787 

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

789 """ 

790 return self.make_model_form( 

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

792 ) 

793 

794 def save_edit_form(self, form): 

795 """ 

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

797 

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

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

800 non-standard use cases. 

801 

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

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

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

805 :meth:`redirect_after_edit()`. 

806 

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

808 """ 

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

810 warnings.warn( 

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

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

813 DeprecationWarning, 

814 ) 

815 return self.edit_save_form(form) 

816 

817 obj = self.objectify(form) 

818 self.persist(obj) 

819 return obj 

820 

821 def redirect_after_edit(self, result): 

822 """ 

823 Must return a redirect, following successful save of the 

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

825 

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

827 

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

829 """ 

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

831 

832 ############################## 

833 # delete methods 

834 ############################## 

835 

836 def delete(self): 

837 """ 

838 View to "delete" a model record. 

839 

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

841 

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

843 is true. 

844 

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

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

847 :meth:`save_delete_form()` and then 

848 :meth:`redirect_after_delete()`. 

849 """ 

850 self.deleting = True 

851 instance = self.get_instance() 

852 

853 if not self.is_deletable(instance): 

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

855 

856 form = self.make_delete_form(instance) 

857 

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

859 if form.validate() is not False: 

860 

861 try: 

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

863 form 

864 ) 

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

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

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

868 else: 

869 return self.redirect_after_delete(result) 

870 

871 context = { 

872 "instance": instance, 

873 "form": form, 

874 } 

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

876 

877 def make_delete_form(self, obj): 

878 """ 

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

880 :meth:`delete()`. 

881 

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

883 twist: 

884 

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

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

887 explicitly marked readonly. 

888 

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

890 """ 

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

892 form = self.make_model_form( 

893 obj, 

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

895 button_label_submit="DELETE Forever", 

896 button_icon_submit="trash", 

897 button_type_submit="is-danger", 

898 ) 

899 

900 # ..but *all* fields are readonly 

901 form.readonly_fields = set(form.fields) 

902 return form 

903 

904 def save_delete_form(self, form): 

905 """ 

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

907 

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

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

910 it could also/instead override this method. 

911 

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

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

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

915 

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

917 """ 

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

919 warnings.warn( 

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

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

922 DeprecationWarning, 

923 ) 

924 self.delete_save_form(form) 

925 return 

926 

927 obj = form.model_instance 

928 self.delete_instance(obj) 

929 

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

931 """ 

932 Must return a redirect, following successful save of the 

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

934 

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

936 

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

938 """ 

939 return self.redirect(self.get_index_url()) 

940 

941 def delete_instance(self, obj): 

942 """ 

943 Delete the given model instance. 

944 

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

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

947 needed. 

948 

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

950 """ 

951 session = self.app.get_session(obj) 

952 session.delete(obj) 

953 

954 def delete_bulk(self): 

955 """ 

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

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

958 

959 This usually corresponds to a URL like 

960 ``/widgets/delete-bulk``. 

961 

962 By default, this view is included only if 

963 :attr:`deletable_bulk` is true. 

964 

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

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

967 

968 Subclass normally should not override this method, but rather 

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

970 this one: 

971 

972 * :meth:`delete_bulk_action()` 

973 """ 

974 

975 # get current data set from grid 

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

977 grid = self.make_model_grid(paginated=False) 

978 data = grid.get_visible_data() 

979 

980 if self.deletable_bulk_quick: 

981 

982 # delete it all and go back to listing 

983 self.delete_bulk_action(data) 

984 return self.redirect(self.get_index_url()) 

985 

986 # start thread for delete; show progress page 

987 route_prefix = self.get_route_prefix() 

988 key = f"{route_prefix}.delete_bulk" 

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

990 thread = threading.Thread( 

991 target=self.delete_bulk_thread, 

992 args=(data,), 

993 kwargs={"progress": progress}, 

994 ) 

995 thread.start() 

996 return self.render_progress(progress) 

997 

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

999 self, query, progress=None 

1000 ): 

1001 """ """ 

1002 session = self.app.make_session() 

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

1004 

1005 def onerror(): 

1006 log.warning( 

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

1008 len(records), 

1009 self.get_model_title_plural(), 

1010 exc_info=True, 

1011 ) 

1012 

1013 self.do_thread_body( 

1014 self.delete_bulk_action, 

1015 (records,), 

1016 {"progress": progress}, 

1017 onerror, 

1018 session=session, 

1019 progress=progress, 

1020 ) 

1021 

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

1023 """ 

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

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

1026 

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

1028 record, and if that returns true then it calls 

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

1030 updated if one is provided. 

1031 

1032 Subclass should override if needed. 

1033 """ 

1034 model_title_plural = self.get_model_title_plural() 

1035 

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

1037 if self.is_deletable(obj): 

1038 self.delete_instance(obj) 

1039 

1040 self.app.progress_loop( 

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

1042 ) 

1043 

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

1045 """ """ 

1046 route_prefix = self.get_route_prefix() 

1047 

1048 label = HTML.literal( 

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

1050 ) 

1051 button = self.make_button( 

1052 label, 

1053 variant="is-danger", 

1054 icon_left="trash", 

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

1056 ) 

1057 

1058 form = HTML.tag( 

1059 "form", 

1060 method="post", 

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

1062 ref="deleteResultsForm", 

1063 class_="control", 

1064 c=[ 

1065 render_csrf_token(self.request), 

1066 button, 

1067 ], 

1068 ) 

1069 return form 

1070 

1071 ############################## 

1072 # version history methods 

1073 ############################## 

1074 

1075 @classmethod 

1076 def is_versioned(cls): 

1077 """ 

1078 Returns boolean indicating whether the model class is 

1079 configured for SQLAlchemy-Continuum versioning. 

1080 

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

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

1083 setting the ``model_is_versioned`` attribute:: 

1084 

1085 class WidgetView(MasterView): 

1086 model_class = Widget 

1087 model_is_versioned = False 

1088 

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

1090 

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

1092 ``False``. 

1093 """ 

1094 if hasattr(cls, "model_is_versioned"): 

1095 return cls.model_is_versioned 

1096 

1097 model_class = cls.get_model_class() 

1098 if hasattr(model_class, "__versioned__"): 

1099 return True 

1100 

1101 return False 

1102 

1103 @classmethod 

1104 def get_model_version_class(cls): 

1105 """ 

1106 Returns the version class for the master model class. 

1107 

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

1109 """ 

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

1111 

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

1113 

1114 def should_expose_versions(self): 

1115 """ 

1116 Returns boolean indicating whether versioning history should 

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

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

1119 

1120 * :meth:`is_versioned()` 

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

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

1123 

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

1125 user; else ``False``. 

1126 """ 

1127 if not self.is_versioned(): 

1128 return False 

1129 

1130 if not self.app.continuum_is_enabled(): 

1131 return False 

1132 

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

1134 return False 

1135 

1136 return True 

1137 

1138 def view_versions(self): 

1139 """ 

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

1141 :meth:`view_version()`. 

1142 

1143 This usually corresponds to a URL like 

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

1145 for the record. 

1146 

1147 By default, this view is included only if 

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

1149 

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

1151 record's version history. 

1152 

1153 See also: 

1154 

1155 * :meth:`make_version_grid()` 

1156 """ 

1157 instance = self.get_instance() 

1158 instance_title = self.get_instance_title(instance) 

1159 grid = self.make_version_grid(instance) 

1160 

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

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

1163 context = grid.get_vue_context() 

1164 if grid.paginated and grid.paginate_on_backend: 

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

1166 return self.json_response(context) 

1167 

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

1169 

1170 instance_link = tags.link_to( 

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

1172 ) 

1173 

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

1175 [index_link, instance_link] 

1176 ) 

1177 

1178 return self.render_to_response( 

1179 "view_versions", 

1180 { 

1181 "index_title_rendered": index_title_rendered, 

1182 "instance": instance, 

1183 "instance_title": instance_title, 

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

1185 "grid": grid, 

1186 }, 

1187 ) 

1188 

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

1190 """ 

1191 Create and return a grid for use with the 

1192 :meth:`view_versions()` view. 

1193 

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

1195 

1196 * :meth:`get_version_grid_key()` 

1197 * :meth:`get_version_grid_columns()` 

1198 * :meth:`get_version_grid_data()` 

1199 * :meth:`configure_version_grid()` 

1200 

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

1202 """ 

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

1204 

1205 route_prefix = self.get_route_prefix() 

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

1207 if not instance: 

1208 instance = self.get_instance() 

1209 

1210 if "key" not in kwargs: 

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

1212 

1213 if "model_class" not in kwargs: 

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

1215 

1216 if "columns" not in kwargs: 

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

1218 

1219 if "data" not in kwargs: 

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

1221 

1222 if "actions" not in kwargs: 

1223 route = f"{route_prefix}.version" 

1224 

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

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

1227 

1228 kwargs["actions"] = [ 

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

1230 ] 

1231 

1232 kwargs.setdefault("paginated", True) 

1233 

1234 grid = self.make_grid(**kwargs) 

1235 self.configure_version_grid(grid) 

1236 grid.load_settings() 

1237 return grid 

1238 

1239 @classmethod 

1240 def get_version_grid_key(cls): 

1241 """ 

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

1243 sort/filter options etc. 

1244 

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

1246 

1247 :returns: Grid key as string 

1248 """ 

1249 if hasattr(cls, "version_grid_key"): 

1250 return cls.version_grid_key 

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

1252 

1253 def get_version_grid_columns(self): 

1254 """ 

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

1256 :meth:`view_versions()` view. 

1257 

1258 This is normally called automatically by 

1259 :meth:`make_version_grid()`. 

1260 

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

1262 cases, or can override this method if needed. 

1263 

1264 :returns: List of string column names 

1265 """ 

1266 if hasattr(self, "version_grid_columns"): 

1267 return self.version_grid_columns 

1268 

1269 return [ 

1270 "id", 

1271 "issued_at", 

1272 "user", 

1273 "remote_addr", 

1274 "comment", 

1275 ] 

1276 

1277 def get_version_grid_data(self, instance): 

1278 """ 

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

1280 view. 

1281 

1282 This is normally called automatically by 

1283 :meth:`make_version_grid()`. 

1284 

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

1286 records which are associated with versions of the given model 

1287 instance. See also: 

1288 

1289 * :meth:`get_version_joins()` 

1290 * :meth:`normalize_version_joins()` 

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

1292 

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

1294 """ 

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

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

1297 model_transaction_query, 

1298 ) 

1299 

1300 model_class = self.get_model_class() 

1301 txncls = continuum.transaction_class(model_class) 

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

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

1304 

1305 def get_version_joins(self): 

1306 """ 

1307 Override this method to declare additional version tables 

1308 which should be joined when showing the overall revision 

1309 history for a given model instance. 

1310 

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

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

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

1314 

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

1316 

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

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

1319 

1320 def get_version_joins(self): 

1321 model = self.app.model 

1322 return super().get_version_joins() + [ 

1323 model.UserExtension, 

1324 ] 

1325 

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

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

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

1329 

1330 def get_version_joins(self): 

1331 model = self.app.model 

1332 return super().get_version_joins() + [ 

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

1334 ] 

1335 

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

1337 """ 

1338 return [] 

1339 

1340 def normalize_version_joins(self): 

1341 """ 

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

1343 the result, which will then get passed along to 

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

1345 

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

1347 override :meth:`get_version_joins()`. 

1348 

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

1350 conforming to what is needed for the query function. 

1351 

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

1353 

1354 :returns: List of version joins info. 

1355 """ 

1356 joins = [] 

1357 for join in self.get_version_joins(): 

1358 if not isinstance(join, tuple): 

1359 join = (join, "uuid", "uuid") 

1360 joins.append(join) 

1361 return joins 

1362 

1363 def configure_version_grid(self, g): 

1364 """ 

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

1366 

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

1368 

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

1370 """ 

1371 # id 

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

1373 # g.set_link("id") 

1374 

1375 # issued_at 

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

1377 g.set_link("issued_at") 

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

1379 

1380 # user 

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

1382 g.set_link("user") 

1383 

1384 # remote_addr 

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

1386 

1387 # comment 

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

1389 

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

1391 self, txn, key, value 

1392 ): 

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

1394 

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

1396 """ 

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

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

1399 

1400 This usually corresponds to a URL like 

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

1402 key/ID for the record and YYY represents a 

1403 SQLAlchemy-Continuum ``transaction.id``. 

1404 

1405 By default, this view is included only if 

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

1407 

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

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

1410 

1411 See also: 

1412 

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

1414 * :meth:`get_relevant_versions()` 

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

1416 """ 

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

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

1419 model_transaction_query, 

1420 ) 

1421 

1422 instance = self.get_instance() 

1423 model_class = self.get_model_class() 

1424 route_prefix = self.get_route_prefix() 

1425 txncls = continuum.transaction_class(model_class) 

1426 transactions = model_transaction_query( 

1427 instance, joins=self.normalize_version_joins() 

1428 ) 

1429 

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

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

1432 if not txn: 

1433 raise self.notfound() 

1434 

1435 prev_url = None 

1436 older = ( 

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

1438 .filter(txncls.id != txnid) 

1439 .order_by(txncls.issued_at.desc()) 

1440 .first() 

1441 ) 

1442 if older: 

1443 prev_url = self.request.route_url( 

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

1445 ) 

1446 

1447 next_url = None 

1448 newer = ( 

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

1450 .filter(txncls.id != txnid) 

1451 .order_by(txncls.issued_at) 

1452 .first() 

1453 ) 

1454 if newer: 

1455 next_url = self.request.route_url( 

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

1457 ) 

1458 

1459 version_diffs = [ 

1460 VersionDiff(self.config, version) 

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

1462 ] 

1463 

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

1465 

1466 instance_title = self.get_instance_title(instance) 

1467 instance_link = tags.link_to( 

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

1469 ) 

1470 

1471 history_link = tags.link_to( 

1472 "history", 

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

1474 ) 

1475 

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

1477 [index_link, instance_link, history_link] 

1478 ) 

1479 

1480 return self.render_to_response( 

1481 "view_version", 

1482 { 

1483 "index_title_rendered": index_title_rendered, 

1484 "instance": instance, 

1485 "instance_title": instance_title, 

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

1487 "transaction": txn, 

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

1489 "version_diffs": version_diffs, 

1490 "show_prev_next": True, 

1491 "prev_url": prev_url, 

1492 "next_url": next_url, 

1493 }, 

1494 ) 

1495 

1496 def get_relevant_versions(self, transaction, instance): 

1497 """ 

1498 Should return all version records pertaining to the given 

1499 model instance and transaction. 

1500 

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

1502 

1503 :param transaction: SQLAlchemy-Continuum ``transaction`` 

1504 record/instance. 

1505 

1506 :param instance: Instance of the model class. 

1507 

1508 :returns: List of version records. 

1509 """ 

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

1511 

1512 session = self.Session() 

1513 vercls = self.get_model_version_class() 

1514 versions = [] 

1515 

1516 # first get all versions for the model instance proper 

1517 versions.extend( 

1518 session.query(vercls) 

1519 .filter(vercls.transaction == transaction) 

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

1521 .all() 

1522 ) 

1523 

1524 # then get all related versions, per declared joins 

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

1526 child_vercls = continuum.version_class(child_class) 

1527 versions.extend( 

1528 session.query(child_vercls) 

1529 .filter(child_vercls.transaction == transaction) 

1530 .filter( 

1531 getattr(child_vercls, foreign_attr) 

1532 == getattr(instance, primary_attr) 

1533 ) 

1534 ) 

1535 

1536 return versions 

1537 

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

1539 # autocomplete methods 

1540 ############################## 

1541 

1542 def autocomplete(self): 

1543 """ 

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

1545 list of autocomplete results to match. 

1546 

1547 By default, this view is included only if 

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

1549 like ``/widgets/autocomplete``. 

1550 

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

1552 rather should override the others which this calls: 

1553 

1554 * :meth:`autocomplete_data()` 

1555 * :meth:`autocomplete_normalize()` 

1556 """ 

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

1558 if not term: 

1559 return [] 

1560 

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

1562 if not data: 

1563 return [] 

1564 

1565 max_results = 100 # TODO 

1566 

1567 results = [] 

1568 for obj in data[:max_results]: 

1569 normal = self.autocomplete_normalize(obj) 

1570 if normal: 

1571 results.append(normal) 

1572 

1573 return results 

1574 

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

1576 """ 

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

1578 based on autocomplete search term. This is called by 

1579 :meth:`autocomplete()`. 

1580 

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

1582 

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

1584 

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

1586 """ 

1587 return None 

1588 

1589 def autocomplete_normalize(self, obj): 

1590 """ 

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

1592 record, suitable for autocomplete JSON results. This is 

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

1594 

1595 Subclass may need to override this; default logic is 

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

1597 "autocomplete results" dict for the object:: 

1598 

1599 { 

1600 'value': obj.uuid, 

1601 'label': str(obj), 

1602 } 

1603 

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

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

1606 

1607 :param obj: Model record/instance. 

1608 

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

1610 above. 

1611 """ 

1612 return { 

1613 "value": obj.uuid, 

1614 "label": str(obj), 

1615 } 

1616 

1617 ############################## 

1618 # download methods 

1619 ############################## 

1620 

1621 def download(self): 

1622 """ 

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

1624 

1625 This usually corresponds to a URL like 

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

1627 for the record. 

1628 

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

1630 is true. 

1631 

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

1633 it as a file download response to the client. 

1634 

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

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

1637 files associated with the model record. This filename is 

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

1639 

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

1641 

1642 Subclass normally should not override this method, but rather 

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

1644 this one: 

1645 

1646 * :meth:`download_path()` 

1647 """ 

1648 obj = self.get_instance() 

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

1650 

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

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

1653 return self.notfound() 

1654 

1655 return self.file_response(path) 

1656 

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

1658 """ 

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

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

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

1662 

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

1664 

1665 :param obj: Refefence to the model instance. 

1666 

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

1668 

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

1670 

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

1672 file path should be returned, if applicable. 

1673 

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

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

1676 response. 

1677 """ 

1678 return None 

1679 

1680 ############################## 

1681 # execute methods 

1682 ############################## 

1683 

1684 def execute(self): 

1685 """ 

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

1687 

1688 This usually corresponds to a URL like 

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

1690 for the record. 

1691 

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

1693 true. 

1694 

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

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

1697 

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

1699 * batches (not yet implemented; 

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

1701 Manual) 

1702 

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

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

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

1706 live" the data held within the batch. 

1707 

1708 Subclass normally should not override this method, but rather 

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

1710 this one: 

1711 

1712 * :meth:`execute_instance()` 

1713 """ 

1714 route_prefix = self.get_route_prefix() 

1715 model_title = self.get_model_title() 

1716 obj = self.get_instance() 

1717 

1718 # make the progress tracker 

1719 progress = self.make_progress( 

1720 f"{route_prefix}.execute", 

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

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

1723 ) 

1724 

1725 # start thread for execute; show progress page 

1726 key = self.request.matchdict 

1727 thread = threading.Thread( 

1728 target=self.execute_thread, 

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

1730 kwargs={"progress": progress}, 

1731 ) 

1732 thread.start() 

1733 return self.render_progress( 

1734 progress, 

1735 context={ 

1736 "instance": obj, 

1737 }, 

1738 template=self.execute_progress_template, 

1739 ) 

1740 

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

1742 """ 

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

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

1745 

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

1747 

1748 :param obj: Reference to the model instance. 

1749 

1750 :param user: Reference to the 

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

1752 is doing the execute. 

1753 

1754 :param progress: Optional progress indicator factory. 

1755 """ 

1756 

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

1758 self, key, user_uuid, progress=None 

1759 ): 

1760 """ """ 

1761 model = self.app.model 

1762 model_title = self.get_model_title() 

1763 

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

1765 session = self.app.make_session() 

1766 

1767 # fetch model instance and user for this session 

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

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

1770 

1771 try: 

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

1773 

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

1775 session.rollback() 

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

1777 if progress: 

1778 progress.handle_error(error) 

1779 

1780 else: 

1781 session.commit() 

1782 if progress: 

1783 progress.handle_success() 

1784 

1785 finally: 

1786 session.close() 

1787 

1788 ############################## 

1789 # configure methods 

1790 ############################## 

1791 

1792 def configure(self, session=None): 

1793 """ 

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

1795 this master view and/or model. 

1796 

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

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

1799 

1800 The expected workflow is as follows: 

1801 

1802 * user navigates to Configure page 

1803 * user modifies settings and clicks Save 

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

1805 * then it saves user-submitted settings 

1806 

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

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

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

1810 

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

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

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

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

1815 

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

1817 only provide their basic definitions via 

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

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

1820 to achieve the aim(s). 

1821 

1822 See also related methods, used by this one: 

1823 

1824 * :meth:`configure_get_simple_settings()` 

1825 * :meth:`configure_get_context()` 

1826 * :meth:`configure_gather_settings()` 

1827 * :meth:`configure_remove_settings()` 

1828 * :meth:`configure_save_settings()` 

1829 """ 

1830 self.configuring = True 

1831 config_title = self.get_config_title() 

1832 

1833 # was form submitted? 

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

1835 

1836 # maybe just remove settings 

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

1838 self.configure_remove_settings(session=session) 

1839 self.request.session.flash( 

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

1841 ) 

1842 

1843 # reload configure page 

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

1845 

1846 # gather/save settings 

1847 data = get_form_data(self.request) 

1848 settings = self.configure_gather_settings(data) 

1849 self.configure_remove_settings(session=session) 

1850 self.configure_save_settings(settings, session=session) 

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

1852 

1853 # reload configure page 

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

1855 

1856 # render configure page 

1857 context = self.configure_get_context() 

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

1859 

1860 def configure_get_context( 

1861 self, 

1862 simple_settings=None, 

1863 ): 

1864 """ 

1865 Returns the full context dict, for rendering the 

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

1867 

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

1869 to just name/value). 

1870 

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

1872 "complex" settings etc. 

1873 

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

1875 already initialized. Otherwise it is retrieved via 

1876 :meth:`configure_get_simple_settings()`. 

1877 

1878 :returns: Context dict for the page template. 

1879 """ 

1880 context = {} 

1881 

1882 # simple settings 

1883 if simple_settings is None: 

1884 simple_settings = self.configure_get_simple_settings() 

1885 if simple_settings: 

1886 

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

1888 normalized = {} 

1889 for simple in simple_settings: 

1890 

1891 # name 

1892 name = simple["name"] 

1893 

1894 # value 

1895 if "value" in simple: 

1896 value = simple["value"] 

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

1898 value = self.config.get_bool( 

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

1900 ) 

1901 else: 

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

1903 

1904 normalized[name] = value 

1905 

1906 # add to template context 

1907 context["simple_settings"] = normalized 

1908 

1909 return context 

1910 

1911 def configure_get_simple_settings(self): 

1912 """ 

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

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

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

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

1917 part of this method's return value.) 

1918 

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

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

1921 

1922 The setting definitions returned must each be a dict of 

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

1924 setting might be:: 

1925 

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

1927 

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

1929 is a more complete example:: 

1930 

1931 { 

1932 'name': 'wutta.production', 

1933 'type': bool, 

1934 'default': False, 

1935 'save_if_empty': False, 

1936 } 

1937 

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

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

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

1941 

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

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

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

1945 

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

1947 Note that their order does not matter since the template 

1948 must explicitly define field layout etc. 

1949 """ 

1950 return [] 

1951 

1952 def configure_gather_settings( 

1953 self, 

1954 data, 

1955 simple_settings=None, 

1956 ): 

1957 """ 

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

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

1960 

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

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

1963 the simple setting definitions. 

1964 

1965 Subclass may need to override this method if complex settings 

1966 are required. 

1967 

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

1969 

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

1971 already initialized. Otherwise it is retrieved via 

1972 :meth:`configure_get_simple_settings()`. 

1973 

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

1975 in spirit to the definition syntax used in 

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

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

1978 

1979 { 

1980 'name': 'wutta.app_title', 

1981 'value': 'Wutta Wutta', 

1982 } 

1983 

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

1985 

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

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

1988 contain all of them. 

1989 

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

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

1992 saved to DB) unless the setting definition has the 

1993 ``save_if_empty`` flag set. 

1994 """ 

1995 settings = [] 

1996 

1997 # simple settings 

1998 if simple_settings is None: 

1999 simple_settings = self.configure_get_simple_settings() 

2000 if simple_settings: 

2001 

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

2003 for simple in simple_settings: 

2004 name = simple["name"] 

2005 

2006 if name in data: 

2007 value = data[name] 

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

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

2010 value = False 

2011 else: 

2012 value = simple.get("default") 

2013 

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

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

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

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

2018 elif value is None: 

2019 value = "" 

2020 else: 

2021 value = str(value) 

2022 

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

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

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

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

2027 

2028 return settings 

2029 

2030 def configure_remove_settings( 

2031 self, 

2032 simple_settings=None, 

2033 session=None, 

2034 ): 

2035 """ 

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

2037 :meth:`configure()`. 

2038 

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

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

2041 

2042 The default logic can handle this automatically for simple 

2043 settings; subclass must override for any complex settings. 

2044 

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

2046 already initialized. Otherwise it is retrieved via 

2047 :meth:`configure_get_simple_settings()`. 

2048 """ 

2049 names = [] 

2050 

2051 # simple settings 

2052 if simple_settings is None: 

2053 simple_settings = self.configure_get_simple_settings() 

2054 if simple_settings: 

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

2056 

2057 if names: 

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

2059 # point to our primary app DB 

2060 session = session or self.Session() 

2061 for name in names: 

2062 self.app.delete_setting(session, name) 

2063 

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

2065 """ 

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

2067 :meth:`configure()`. 

2068 

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

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

2071 

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

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

2074 """ 

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

2076 # to our primary app DB 

2077 session = session or self.Session() 

2078 for setting in settings: 

2079 self.app.save_setting( 

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

2081 ) 

2082 

2083 ############################## 

2084 # grid rendering methods 

2085 ############################## 

2086 

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

2088 """ 

2089 Custom grid value renderer for "boolean" fields. 

2090 

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

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

2093 To use this feature for your grid:: 

2094 

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

2096 """ 

2097 if value is None: 

2098 return None 

2099 

2100 return "Yes" if value else "No" 

2101 

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

2103 """ 

2104 Custom grid value renderer for "currency" fields. 

2105 

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

2107 decimal as appropriate, and add the currency symbol. 

2108 

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

2110 default is 2 places. 

2111 

2112 To use this feature for your grid:: 

2113 

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

2115 

2116 # you can also override scale 

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

2118 """ 

2119 

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

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

2122 value = record[key] 

2123 

2124 if value is None: 

2125 return None 

2126 

2127 if value < 0: 

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

2129 return fmt.format(0 - value) 

2130 

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

2132 return fmt.format(value) 

2133 

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

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

2136 ): 

2137 """ """ 

2138 warnings.warn( 

2139 "MasterView.grid_render_datetime() is deprecated; " 

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

2141 DeprecationWarning, 

2142 stacklevel=2, 

2143 ) 

2144 

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

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

2147 value = record[key] 

2148 

2149 if value is None: 

2150 return None 

2151 

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

2153 

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

2155 """ 

2156 Custom grid value renderer for "enum" fields. 

2157 

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

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

2160 

2161 To use this feature for your grid:: 

2162 

2163 from enum import Enum 

2164 

2165 class MyEnum(Enum): 

2166 ONE = 1 

2167 TWO = 2 

2168 THREE = 3 

2169 

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

2171 """ 

2172 if enum: 

2173 original = record[key] 

2174 if original: 

2175 return original.name 

2176 

2177 return value 

2178 

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

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

2181 ): 

2182 """ 

2183 Custom grid value renderer for "notes" fields. 

2184 

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

2186 characters, it is returned as-is. 

2187 

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

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

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

2191 mouse hover. 

2192 

2193 To use this feature for your grid:: 

2194 

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

2196 

2197 # you can also override maxlen 

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

2199 """ 

2200 if value is None: 

2201 return None 

2202 

2203 if len(value) < maxlen: 

2204 return value 

2205 

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

2207 

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

2209 # support methods 

2210 ############################## 

2211 

2212 def get_class_hierarchy(self, topfirst=True): 

2213 """ 

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

2215 class inherits. 

2216 

2217 This is a wrapper around 

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

2219 """ 

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

2221 

2222 def has_perm(self, name): 

2223 """ 

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

2225 

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

2227 ``name`` before passing it on to 

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

2229 

2230 For instance within the 

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

2232 result:: 

2233 

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

2235 

2236 self.has_perm('edit') 

2237 

2238 So this shortcut only applies to permissions defined for the 

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

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

2241 different prefix). 

2242 """ 

2243 permission_prefix = self.get_permission_prefix() 

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

2245 

2246 def has_any_perm(self, *names): 

2247 """ 

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

2249 permissions. 

2250 

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

2252 none do, returns ``False``. 

2253 """ 

2254 for name in names: 

2255 if self.has_perm(name): 

2256 return True 

2257 return False 

2258 

2259 def make_button( 

2260 self, 

2261 label, 

2262 variant=None, 

2263 primary=False, 

2264 url=None, 

2265 **kwargs, 

2266 ): 

2267 """ 

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

2269 

2270 :param label: Text label for the button. 

2271 

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

2273 for the button. Buefy and Oruga represent this differently 

2274 but this logic expects the Buefy format 

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

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

2277 terminology. 

2278 

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

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

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

2282 

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

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

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

2286 

2287 This is the preferred method where applicable, since it 

2288 avoids the Buefy vs. Oruga confusion, and the 

2289 implementation can change in the future. 

2290 

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

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

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

2294 

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

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

2297 attributes on the button tag. 

2298 

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

2300 reasons. 

2301 

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

2303 along the lines of: 

2304 

2305 .. code-block:: 

2306 

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

2308 icon-pack="fas" 

2309 icon-left="hand-pointer"> 

2310 Click Me 

2311 </b-button> 

2312 """ 

2313 btn_kw = kwargs 

2314 btn_kw.setdefault("c", label) 

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

2316 

2317 if "type" not in btn_kw: 

2318 if variant: 

2319 btn_kw["type"] = variant 

2320 elif primary: 

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

2322 

2323 if url: 

2324 btn_kw["href"] = url 

2325 

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

2327 

2328 if url: 

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

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

2331 # we patch that into place manually 

2332 button = str(button) 

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

2334 button = HTML.literal(button) 

2335 

2336 return button 

2337 

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

2339 """ 

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

2341 when viewing the given object. 

2342 

2343 Default logic always returns empty list; subclass can override 

2344 as needed. 

2345 

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

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

2348 see/use. 

2349 

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

2351 

2352 def get_xref_buttons(self, product): 

2353 buttons = [] 

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

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

2356 id=product.external_id) 

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

2358 return buttons 

2359 """ 

2360 return [] 

2361 

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

2363 """ 

2364 Create and return a 

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

2366 given key. 

2367 

2368 This is normally done just before calling 

2369 :meth:`render_progress()`. 

2370 """ 

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

2372 

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

2374 """ 

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

2376 

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

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

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

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

2381 is redirected to the final destination. 

2382 

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

2384 

2385 :param progress: Progress indicator instance as returned by 

2386 :meth:`make_progress()`. 

2387 

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

2389 """ 

2390 template = template or "/progress.mako" 

2391 context = context or {} 

2392 context["progress"] = progress 

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

2394 

2395 def render_to_response(self, template, context): 

2396 """ 

2397 Locate and render an appropriate template, with the given 

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

2399 

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

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

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

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

2404 :meth:`get_fallback_templates()`. 

2405 

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

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

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

2409 

2410 * ``/widgets/edit.mako`` 

2411 * ``/master/edit.mako`` 

2412 

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

2414 It then calls 

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

2416 returns the result. 

2417 

2418 :param template: Base name for the template. 

2419 

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

2421 

2422 :returns: Response object containing the rendered template. 

2423 """ 

2424 defaults = { 

2425 "master": self, 

2426 "route_prefix": self.get_route_prefix(), 

2427 "index_title": self.get_index_title(), 

2428 "index_url": self.get_index_url(), 

2429 "model_title": self.get_model_title(), 

2430 "model_title_plural": self.get_model_title_plural(), 

2431 "config_title": self.get_config_title(), 

2432 } 

2433 

2434 # merge defaults + caller-provided context 

2435 defaults.update(context) 

2436 context = defaults 

2437 

2438 # add crud flags if we have an instance 

2439 if "instance" in context: 

2440 instance = context["instance"] 

2441 if "instance_title" not in context: 

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

2443 if "instance_editable" not in context: 

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

2445 if "instance_deletable" not in context: 

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

2447 

2448 # supplement context further if needed 

2449 context = self.get_template_context(context) 

2450 

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

2452 page_templates = self.get_page_templates(template) 

2453 mako_path = page_templates[0] 

2454 try: 

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

2456 except IOError: 

2457 

2458 # failing that, try one or more fallback templates 

2459 for fallback in page_templates[1:]: 

2460 try: 

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

2462 except IOError: 

2463 pass 

2464 

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

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

2467 # let that error raise on up 

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

2469 

2470 def get_template_context(self, context): 

2471 """ 

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

2473 the current view template. 

2474 

2475 Default logic for this method returns the given context 

2476 unchanged. 

2477 

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

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

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

2481 context only for certain view templates. 

2482 

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

2484 auto-provided by the master view logic. 

2485 

2486 :returns: Final context dict for the template. 

2487 """ 

2488 return context 

2489 

2490 def get_page_templates(self, template): 

2491 """ 

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

2493 render the current page. This is called by 

2494 :meth:`render_to_response()`. 

2495 

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

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

2498 entries becoming more generic. 

2499 

2500 In practice this method defines the first entry but calls 

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

2502 

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

2504 ``'view'``. 

2505 

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

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

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

2509 

2510 [ 

2511 '/widgets/view.mako', 

2512 '/master/view.mako', 

2513 ] 

2514 

2515 """ 

2516 template_prefix = self.get_template_prefix() 

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

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

2519 return page_templates 

2520 

2521 def get_fallback_templates(self, template): 

2522 """ 

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

2524 attempted for rendering the current page. See also 

2525 :meth:`get_page_templates()`. 

2526 

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

2528 ``'view'``. 

2529 

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

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

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

2533 

2534 ['/master/view.mako'] 

2535 """ 

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

2537 

2538 def get_index_title(self): 

2539 """ 

2540 Returns the main index title for the master view. 

2541 

2542 By default this returns the value from 

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

2544 needed. 

2545 """ 

2546 return self.get_model_title_plural() 

2547 

2548 def get_index_url(self, **kwargs): 

2549 """ 

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

2551 

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

2553 """ 

2554 if self.listable: 

2555 route_prefix = self.get_route_prefix() 

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

2557 return None 

2558 

2559 def set_labels(self, obj): 

2560 """ 

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

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

2563 

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

2565 :meth:`configure_form()`. 

2566 

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

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

2569 

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

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

2572 

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

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

2575 """ 

2576 labels = self.collect_labels() 

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

2578 obj.set_label(key, label) 

2579 

2580 def collect_labels(self): 

2581 """ 

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

2583 

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

2585 like so:: 

2586 

2587 from wuttaweb.views import MasterView 

2588 

2589 class WidgetView(MasterView): 

2590 

2591 labels = { 

2592 'id': "Widget ID", 

2593 'serial_no': "Serial Number", 

2594 } 

2595 

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

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

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

2599 wins. 

2600 

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

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

2603 

2604 :returns: Dict of all labels found. 

2605 """ 

2606 labels = {} 

2607 hierarchy = self.get_class_hierarchy() 

2608 for cls in hierarchy: 

2609 if hasattr(cls, "labels"): 

2610 labels.update(cls.labels) 

2611 return labels 

2612 

2613 def make_model_grid( 

2614 self, session=None, **kwargs 

2615 ): # pylint: disable=too-many-branches 

2616 """ 

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

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

2619 

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

2621 

2622 * :meth:`get_grid_key()` 

2623 * :meth:`get_grid_columns()` 

2624 * :meth:`get_grid_data()` 

2625 * :meth:`configure_grid()` 

2626 """ 

2627 route_prefix = self.get_route_prefix() 

2628 

2629 if "key" not in kwargs: 

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

2631 

2632 if "model_class" not in kwargs: 

2633 model_class = self.get_model_class() 

2634 if model_class: 

2635 kwargs["model_class"] = model_class 

2636 

2637 if "columns" not in kwargs: 

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

2639 

2640 if "data" not in kwargs: 

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

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

2643 ) 

2644 

2645 if "actions" not in kwargs: 

2646 actions = [] 

2647 

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

2649 

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

2651 actions.append( 

2652 self.make_grid_action( 

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

2654 ) 

2655 ) 

2656 

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

2658 actions.append( 

2659 self.make_grid_action( 

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

2661 ) 

2662 ) 

2663 

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

2665 actions.append( 

2666 self.make_grid_action( 

2667 "delete", 

2668 icon="trash", 

2669 url=self.get_action_url_delete, 

2670 link_class="has-text-danger", 

2671 ) 

2672 ) 

2673 

2674 kwargs["actions"] = actions 

2675 

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

2677 

2678 if "tools" not in kwargs: 

2679 tools = [] 

2680 

2681 # delete-bulk 

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

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

2684 

2685 # merge 

2686 if mergeable: 

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

2688 button = self.make_button( 

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

2690 primary=True, 

2691 native_type="submit", 

2692 icon_left="object-ungroup", 

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

2694 ) 

2695 csrf = render_csrf_token(self.request) 

2696 html = ( 

2697 tags.form( 

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

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

2700 ) 

2701 + csrf 

2702 + hidden 

2703 + button 

2704 + tags.end_form() 

2705 ) 

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

2707 

2708 kwargs["tools"] = tools 

2709 

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

2711 if hasattr(self, "grid_row_class"): 

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

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

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

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

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

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

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

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

2720 

2721 grid = self.make_grid(**kwargs) 

2722 self.configure_grid(grid) 

2723 grid.load_settings() 

2724 return grid 

2725 

2726 def get_grid_columns(self): 

2727 """ 

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

2729 :meth:`index()` view. 

2730 

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

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

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

2734 

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

2736 (try to) generate its own default list. 

2737 

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

2739 can override this method if needed. 

2740 

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

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

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

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

2745 remove or replace some of those within 

2746 :meth:`configure_grid()`. 

2747 """ 

2748 if hasattr(self, "grid_columns"): 

2749 return self.grid_columns 

2750 return None 

2751 

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

2753 self, columns=None, session=None 

2754 ): 

2755 """ 

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

2757 

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

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

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

2761 

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

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

2764 empty list. Subclass should override as needed. 

2765 """ 

2766 query = self.get_query(session=session) 

2767 if query: 

2768 return query 

2769 return [] 

2770 

2771 def get_query(self, session=None): 

2772 """ 

2773 Returns the main SQLAlchemy query object for the 

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

2775 :meth:`get_grid_data()`. 

2776 

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

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

2779 """ 

2780 model_class = self.get_model_class() 

2781 if model_class: 

2782 session = session or self.Session() 

2783 return session.query(model_class) 

2784 return None 

2785 

2786 def configure_grid(self, grid): 

2787 """ 

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

2789 

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

2791 

2792 There is minimal default logic here; subclass should override 

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

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

2795 based on request details etc. 

2796 """ 

2797 if "uuid" in grid.columns: 

2798 grid.columns.remove("uuid") 

2799 

2800 self.set_labels(grid) 

2801 

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

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

2804 # for key in self.get_model_key(): 

2805 # grid.set_link(key) 

2806 

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

2808 """ 

2809 This should return the appropriate model instance, based on 

2810 the ``matchdict`` of model keys. 

2811 

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

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

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

2815 (route/params). 

2816 

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

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

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

2820 

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

2822 obj = self.get_instance(matchdict=keys) 

2823 

2824 Although some models may have different, possibly composite 

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

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

2827 

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

2829 raise a 404 error, 

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

2831 

2832 Default implementation of this method should work okay for 

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

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

2835 may need to define. 

2836 

2837 .. warning:: 

2838 

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

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

2841 

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

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

2844 

2845 def get_instance(self, **kwargs): 

2846 

2847 # ..try to locate instance.. 

2848 obj = self.locate_instance_somehow() 

2849 

2850 if not obj: 

2851 

2852 # NB. THIS MAY NOT WORK AS EXPECTED 

2853 #return self.notfound() 

2854 

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

2856 raise self.notfound() 

2857 

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

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

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

2861 will kick in and control flow goes elsewhere. 

2862 """ 

2863 model_class = self.get_model_class() 

2864 if model_class: 

2865 session = session or self.Session() 

2866 matchdict = matchdict or self.request.matchdict 

2867 

2868 def filtr(query, model_key): 

2869 key = matchdict[model_key] 

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

2871 return query 

2872 

2873 query = session.query(model_class) 

2874 

2875 for key in self.get_model_key(): 

2876 query = filtr(query, key) 

2877 

2878 try: 

2879 return query.one() 

2880 except orm.exc.NoResultFound: 

2881 pass 

2882 

2883 raise self.notfound() 

2884 

2885 raise NotImplementedError( 

2886 "you must define get_instance() method " 

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

2888 ) 

2889 

2890 def get_instance_title(self, instance): 

2891 """ 

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

2893 in the page title when viewing etc. 

2894 

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

2896 subclass may override if needed. 

2897 """ 

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

2899 

2900 def get_action_route_kwargs(self, obj): 

2901 """ 

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

2903 

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

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

2906 

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

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

2909 the object. 

2910 

2911 :param obj: Model instance object. 

2912 

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

2914 """ 

2915 try: 

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

2917 except TypeError: 

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

2919 

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

2921 """ 

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

2923 

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

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

2926 

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

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

2929 returns the result. 

2930 

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

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

2933 

2934 :param obj: Model instance object. 

2935 

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

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

2938 """ 

2939 kw = self.get_action_route_kwargs(obj) 

2940 kw.update(kwargs) 

2941 route_prefix = self.get_route_prefix() 

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

2943 

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

2945 """ 

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

2947 

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

2949 represents the object's key/ID. 

2950 

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

2952 """ 

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

2954 

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

2956 """ 

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

2958 applicable. 

2959 

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

2961 ``XXX`` represents the object's key/ID. 

2962 

2963 This first calls :meth:`is_editable()` and if that is false, 

2964 this method will return ``None``. 

2965 

2966 Calls :meth:`get_action_url()` to generate the true URL. 

2967 """ 

2968 if self.is_editable(obj): 

2969 return self.get_action_url("edit", obj) 

2970 return None 

2971 

2972 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument 

2973 """ 

2974 Returns the "delete" grid action URL for the given object, if 

2975 applicable. 

2976 

2977 Most typically this is like ``/widgets/XXX/delete`` where 

2978 ``XXX`` represents the object's key/ID. 

2979 

2980 This first calls :meth:`is_deletable()` and if that is false, 

2981 this method will return ``None``. 

2982 

2983 Calls :meth:`get_action_url()` to generate the true URL. 

2984 """ 

2985 if self.is_deletable(obj): 

2986 return self.get_action_url("delete", obj) 

2987 return None 

2988 

2989 def is_editable(self, obj): # pylint: disable=unused-argument 

2990 """ 

2991 Returns a boolean indicating whether "edit" should be allowed 

2992 for the given model instance (and for current user). 

2993 

2994 By default this always return ``True``; subclass can override 

2995 if needed. 

2996 

2997 Note that the use of this method implies :attr:`editable` is 

2998 true, so the method does not need to check that flag. 

2999 """ 

3000 return True 

3001 

3002 def is_deletable(self, obj): # pylint: disable=unused-argument 

3003 """ 

3004 Returns a boolean indicating whether "delete" should be 

3005 allowed for the given model instance (and for current user). 

3006 

3007 By default this always return ``True``; subclass can override 

3008 if needed. 

3009 

3010 Note that the use of this method implies :attr:`deletable` is 

3011 true, so the method does not need to check that flag. 

3012 """ 

3013 return True 

3014 

3015 def make_model_form(self, model_instance=None, fields=None, **kwargs): 

3016 """ 

3017 Make a form for the "model" represented by this subclass. 

3018 

3019 This method is normally called by all CRUD views: 

3020 

3021 * :meth:`create()` 

3022 * :meth:`view()` 

3023 * :meth:`edit()` 

3024 * :meth:`delete()` 

3025 

3026 The form need not have a ``model_instance``, as in the case of 

3027 :meth:`create()`. And it can be readonly as in the case of 

3028 :meth:`view()` and :meth:`delete()`. 

3029 

3030 If ``fields`` are not provided, :meth:`get_form_fields()` is 

3031 called. Usually a subclass will define :attr:`form_fields` 

3032 but it's only required if :attr:`model_class` is not set. 

3033 

3034 Then :meth:`configure_form()` is called, so subclass can go 

3035 crazy with that as needed. 

3036 

3037 :param model_instance: Model instance/record with which to 

3038 initialize the form data. Not needed for "create" forms. 

3039 

3040 :param fields: Optional fields list for the form. 

3041 

3042 :returns: :class:`~wuttaweb.forms.base.Form` instance 

3043 """ 

3044 if "model_class" not in kwargs: 

3045 model_class = self.get_model_class() 

3046 if model_class: 

3047 kwargs["model_class"] = model_class 

3048 

3049 kwargs["model_instance"] = model_instance 

3050 

3051 if not fields: 

3052 fields = self.get_form_fields() 

3053 if fields: 

3054 kwargs["fields"] = fields 

3055 

3056 form = self.make_form(**kwargs) 

3057 self.configure_form(form) 

3058 return form 

3059 

3060 def get_form_fields(self): 

3061 """ 

3062 Returns the initial list of field names for the model form. 

3063 

3064 This is called by :meth:`make_model_form()`; in the resulting 

3065 :class:`~wuttaweb.forms.base.Form` instance, this becomes 

3066 :attr:`~wuttaweb.forms.base.Form.fields`. 

3067 

3068 This method may return ``None``, in which case the form may 

3069 (try to) generate its own default list. 

3070 

3071 Subclass may define :attr:`form_fields` for simple cases, or 

3072 can override this method if needed. 

3073 

3074 Note that :meth:`configure_form()` may be used to further 

3075 modify the final field list, regardless of what this method 

3076 returns. So a common pattern is to declare all "supported" 

3077 fields by setting :attr:`form_fields` but then optionally 

3078 remove or replace some in :meth:`configure_form()`. 

3079 """ 

3080 if hasattr(self, "form_fields"): 

3081 return self.form_fields 

3082 return None 

3083 

3084 def configure_form(self, form): 

3085 """ 

3086 Configure the given model form, as needed. 

3087 

3088 This is called by :meth:`make_model_form()` - for multiple 

3089 CRUD views (create, view, edit, delete, possibly others). 

3090 

3091 The default logic here does just one thing: when "editing" 

3092 (i.e. in :meth:`edit()` view) then all fields which are part 

3093 of the :attr:`model_key` will be marked via 

3094 :meth:`set_readonly()` so the user cannot change primary key 

3095 values for a record. 

3096 

3097 Subclass may override as needed. The ``form`` param will 

3098 already be "complete" and ready to use as-is, but this method 

3099 can further modify it based on request details etc. 

3100 """ 

3101 form.remove("uuid") 

3102 

3103 self.set_labels(form) 

3104 

3105 # mark key fields as readonly to prevent edit. see also 

3106 # related comments in the objectify() method 

3107 if self.editing: 

3108 for key in self.get_model_key(): 

3109 form.set_readonly(key) 

3110 

3111 def objectify(self, form): 

3112 """ 

3113 Must return a "model instance" object which reflects the 

3114 validated form data. 

3115 

3116 In simple cases this may just return the 

3117 :attr:`~wuttaweb.forms.base.Form.validated` data dict. 

3118 

3119 When dealing with SQLAlchemy models it would return a proper 

3120 mapped instance, creating it if necessary. 

3121 

3122 This is called by various other form-saving methods: 

3123 

3124 * :meth:`save_create_form()` 

3125 * :meth:`save_edit_form()` 

3126 * :meth:`create_row_save_form()` 

3127 

3128 See also :meth:`persist()`. 

3129 

3130 :param form: Reference to the *already validated* 

3131 :class:`~wuttaweb.forms.base.Form` object. See the form's 

3132 :attr:`~wuttaweb.forms.base.Form.validated` attribute for 

3133 the data. 

3134 """ 

3135 

3136 # ColanderAlchemy schema has an objectify() method which will 

3137 # return a populated model instance 

3138 schema = form.get_schema() 

3139 if hasattr(schema, "objectify"): 

3140 return schema.objectify(form.validated, context=form.model_instance) 

3141 

3142 # at this point we likely have no model class, so have to 

3143 # assume we're operating on a simple dict record. we (mostly) 

3144 # want to return that as-is, unless subclass overrides. 

3145 data = dict(form.validated) 

3146 

3147 # nb. we have a unique scenario when *editing* for a simple 

3148 # dict record (no model class). we mark the key fields as 

3149 # readonly in configure_form(), so they aren't part of the 

3150 # data here, but we need to add them back for sake of 

3151 # e.g. generating the 'view' route kwargs for redirect. 

3152 if self.editing: 

3153 obj = self.get_instance() 

3154 for key in self.get_model_key(): 

3155 if key not in data: 

3156 data[key] = obj[key] 

3157 

3158 return data 

3159 

3160 def persist(self, obj, session=None): 

3161 """ 

3162 If applicable, this method should persist ("save") the given 

3163 object's data (e.g. to DB), creating or updating it as needed. 

3164 

3165 This is part of the "submit form" workflow; ``obj`` should be 

3166 a model instance which already reflects the validated form 

3167 data. 

3168 

3169 Note that there is no default logic here, subclass must 

3170 override if needed. 

3171 

3172 :param obj: Model instance object as produced by 

3173 :meth:`objectify()`. 

3174 

3175 See also :meth:`save_create_form()` and 

3176 :meth:`save_edit_form()`, which call this method. 

3177 """ 

3178 model = self.app.model 

3179 model_class = self.get_model_class() 

3180 if model_class and issubclass(model_class, model.Base): 

3181 

3182 # add sqlalchemy model to session 

3183 session = session or self.Session() 

3184 session.add(obj) 

3185 

3186 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments 

3187 self, func, args, kwargs, onerror=None, session=None, progress=None 

3188 ): 

3189 """ 

3190 Generic method to invoke for thread operations. 

3191 

3192 :param func: Callable which performs the actual logic. This 

3193 will be wrapped with a try/except statement for error 

3194 handling. 

3195 

3196 :param args: Tuple of positional arguments to pass to the 

3197 ``func`` callable. 

3198 

3199 :param kwargs: Dict of keyword arguments to pass to the 

3200 ``func`` callable. 

3201 

3202 :param onerror: Optional callback to invoke if ``func`` raises 

3203 an error. It should not expect any arguments. 

3204 

3205 :param session: Optional :term:`db session` in effect. Note 

3206 that if supplied, it will be *committed* (or rolled back on 

3207 error) and *closed* by this method. If you need more 

3208 specialized handling, do not use this method (or don't 

3209 specify the ``session``). 

3210 

3211 :param progress: Optional progress factory. If supplied, this 

3212 is assumed to be a 

3213 :class:`~wuttaweb.progress.SessionProgress` instance, and 

3214 it will be updated per success or failure of ``func`` 

3215 invocation. 

3216 """ 

3217 try: 

3218 func(*args, **kwargs) 

3219 

3220 except Exception as error: # pylint: disable=broad-exception-caught 

3221 if session: 

3222 session.rollback() 

3223 if onerror: 

3224 onerror() 

3225 else: 

3226 log.warning("failed to invoke thread callable: %s", func, exc_info=True) 

3227 if progress: 

3228 progress.handle_error(error) 

3229 

3230 else: 

3231 if session: 

3232 session.commit() 

3233 if progress: 

3234 progress.handle_success() 

3235 

3236 finally: 

3237 if session: 

3238 session.close() 

3239 

3240 ############################## 

3241 # merge methods 

3242 ############################## 

3243 

3244 def merge(self): 

3245 """ 

3246 View for merging two records. 

3247 

3248 By default, this view is included only if :attr:`mergeable` is 

3249 true. It usually maps to a URL like ``/widgets/merge``. 

3250 

3251 A POST request must be used for this view; otherwise it will 

3252 redirect to the :meth:`index()` view. The POST data must 

3253 specify a ``uuids`` param string in 

3254 ``"removing_uuid,keeping_uuid"`` format. 

3255 

3256 The user is first shown a "diff" with the 

3257 removing/keeping/final data records, as simple preview. They 

3258 can swap removing vs. keeping if needed, and when satisfied 

3259 they can "execute" the merge. 

3260 

3261 See also related methods, used by this one: 

3262 

3263 * :meth:`merge_validate_and_execute()` 

3264 * :meth:`merge_get_data()` 

3265 * :meth:`merge_get_final_data()` 

3266 """ 

3267 if self.request.method != "POST": 

3268 return self.redirect(self.get_index_url()) 

3269 

3270 session = self.Session() 

3271 model_class = self.get_model_class() 

3272 

3273 # load records to be kept/removed 

3274 removing = keeping = None 

3275 uuids = self.request.POST.get("uuids", "").split(",") 

3276 if len(uuids) == 2: 

3277 uuid1, uuid2 = uuids 

3278 try: 

3279 uuid1 = UUID(uuid1) 

3280 uuid2 = UUID(uuid2) 

3281 except ValueError: 

3282 pass 

3283 else: 

3284 removing = session.get(model_class, uuid1) 

3285 keeping = session.get(model_class, uuid2) 

3286 

3287 # redirect to listing if record(s) not found 

3288 if not (removing and keeping): 

3289 raise self.redirect(self.get_index_url()) 

3290 

3291 # maybe execute merge 

3292 if self.request.POST.get("execute-merge") == "true": 

3293 if self.merge_validate_and_execute(removing, keeping): 

3294 return self.redirect(self.get_action_url("view", keeping)) 

3295 

3296 removing_data = self.merge_get_data(removing) 

3297 keeping_data = self.merge_get_data(keeping) 

3298 diff = MergeDiff( 

3299 self.config, 

3300 removing_data, 

3301 keeping_data, 

3302 self.merge_get_final_data(removing_data, keeping_data), 

3303 ) 

3304 

3305 context = {"removing": removing, "keeping": keeping, "diff": diff} 

3306 return self.render_to_response("merge", context) 

3307 

3308 def merge_get_simple_fields(self): 

3309 """ 

3310 Return the list of "simple" fields for the merge. 

3311 

3312 These "simple" fields will not have any special handling for 

3313 the merge. In other words the "removing" record values will 

3314 be ignored and the "keeping" record values will remain in 

3315 place, without modification. 

3316 

3317 If the view class defines :attr:`merge_simple_fields`, that 

3318 list is returned as-is. Otherwise the list of columns from 

3319 :attr:`model_class` is returned. 

3320 

3321 :returns: List of simple field names. 

3322 """ 

3323 if self.merge_simple_fields: 

3324 return list(self.merge_simple_fields) 

3325 

3326 mapper = sa.inspect(self.get_model_class()) 

3327 fields = mapper.columns.keys() 

3328 return fields 

3329 

3330 def merge_get_additive_fields(self): 

3331 """ 

3332 Return the list of "additive" fields for the merge. 

3333 

3334 Values from the removing/keeping record will be conceptually 

3335 added together, for each of these fields. 

3336 

3337 If the view class defines :attr:`merge_additive_fields`, that 

3338 list is returned as-is. Otherwise an empty list is returned. 

3339 

3340 :returns: List of additive field names. 

3341 """ 

3342 if self.merge_additive_fields: 

3343 return list(self.merge_additive_fields) 

3344 return [] 

3345 

3346 def merge_get_coalesce_fields(self): 

3347 """ 

3348 Return the list of "coalesce" fields for the merge. 

3349 

3350 Values from the removing/keeping record will be conceptually 

3351 "coalesced" for each of these fields. 

3352 

3353 If the view class defines :attr:`merge_coalesce_fields`, that 

3354 list is returned as-is. Otherwise an empty list is returned. 

3355 

3356 :returns: List of coalesce field names. 

3357 """ 

3358 if self.merge_coalesce_fields: 

3359 return list(self.merge_coalesce_fields) 

3360 return [] 

3361 

3362 def merge_get_all_fields(self): 

3363 """ 

3364 Return the list of *all* fields for the merge. 

3365 

3366 This will call each of the following methods to collect all 

3367 field names, then it returns the full *sorted* list. 

3368 

3369 * :meth:`merge_get_additive_fields()` 

3370 * :meth:`merge_get_coalesce_fields()` 

3371 * :meth:`merge_get_simple_fields()` 

3372 

3373 :returns: Sorted list of all field names. 

3374 """ 

3375 fields = set() 

3376 fields.update(self.merge_get_simple_fields()) 

3377 fields.update(self.merge_get_additive_fields()) 

3378 fields.update(self.merge_get_coalesce_fields()) 

3379 return sorted(fields) 

3380 

3381 def merge_get_data(self, obj): 

3382 """ 

3383 Return a data dict for the given object, which will be either 

3384 the "removing" or "keeping" record for the merge. 

3385 

3386 By default this calls :meth:`merge_get_all_fields()` and then 

3387 for each field, calls ``getattr()`` on the object. Subclass 

3388 can override as needed for custom logic. 

3389 

3390 :param obj: Reference to model/record instance. 

3391 

3392 :returns: Data dict with all field values. 

3393 """ 

3394 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()} 

3395 

3396 def merge_get_final_data(self, removing, keeping): 

3397 """ 

3398 Return the "final" data dict for the merge. 

3399 

3400 The result will be identical to the "keeping" record, for all 

3401 "simple" fields. However the "additive" and "coalesce" fields 

3402 are handled specially per their nature, in which case those 

3403 final values may or may not match the "keeping" record. 

3404 

3405 :param removing: Data dict for the "removing" record. 

3406 

3407 :param keeping: Data dict for the "keeping" record. 

3408 

3409 :returns: Data dict with all "final" field values. 

3410 

3411 See also: 

3412 

3413 * :meth:`merge()` 

3414 * :meth:`merge_get_additive_fields()` 

3415 * :meth:`merge_get_coalesce_fields()` 

3416 """ 

3417 final = dict(keeping) 

3418 

3419 for field in self.merge_get_additive_fields(): 

3420 if isinstance(keeping[field], list): 

3421 final[field] = sorted(set(removing[field] + keeping[field])) 

3422 else: 

3423 final[field] = removing[field] + keeping[field] 

3424 

3425 for field in self.merge_get_coalesce_fields(): 

3426 if removing[field] is not None and keeping[field] is None: 

3427 final[field] = removing[field] 

3428 elif removing[field] and not keeping[field]: 

3429 final[field] = removing[field] 

3430 

3431 return final 

3432 

3433 def merge_validate_and_execute(self, removing, keeping): 

3434 """ 

3435 Validate and execute a merge for the two given records. It is 

3436 called from :meth:`merge()`. 

3437 

3438 This calls :meth:`merge_why_not()` and if that does not yield 

3439 a reason to prevent the merge, then calls 

3440 :meth:`merge_execute()`. 

3441 

3442 If there was a reason not to merge, or if an error occurs 

3443 during merge execution, a flash warning/error message is set 

3444 to notify the user what happened. 

3445 

3446 :param removing: Reference to the "removing" model instance/record. 

3447 

3448 :param keeping: Reference to the "keeping" model instance/record. 

3449 

3450 :returns: Boolean indicating whether merge execution completed 

3451 successfully. 

3452 """ 

3453 session = self.Session() 

3454 

3455 # validate the merge 

3456 if reason := self.merge_why_not(removing, keeping): 

3457 warning = HTML.tag( 

3458 "p", class_="block", c="Merge cannot proceed:" 

3459 ) + HTML.tag("p", class_="block", c=reason) 

3460 self.request.session.flash(warning, "warning") 

3461 return False 

3462 

3463 # execute the merge 

3464 removed_title = str(removing) 

3465 try: 

3466 self.merge_execute(removing, keeping) 

3467 session.flush() 

3468 except Exception as err: # pylint: disable=broad-exception-caught 

3469 session.rollback() 

3470 log.warning("merge failed", exc_info=True) 

3471 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag( 

3472 "p", class_="block", c=self.app.render_error(err) 

3473 ) 

3474 self.request.session.flash(warning, "error") 

3475 return False 

3476 

3477 self.request.session.flash(f"{removed_title} has been merged into {keeping}") 

3478 return True 

3479 

3480 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument 

3481 """ 

3482 Can return a "reason" why the two given records should not be merged. 

3483 

3484 This returns ``None`` by default, indicating the merge is 

3485 allowed. Subclass can override as needed for custom logic. 

3486 

3487 See also :meth:`merge_validate_and_execute()`. 

3488 

3489 :param removing: Reference to the "removing" model instance/record. 

3490 

3491 :param keeping: Reference to the "keeping" model instance/record. 

3492 

3493 :returns: Reason not to merge (as string), or ``None``. 

3494 """ 

3495 return None 

3496 

3497 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument 

3498 """ 

3499 Execute the actual merge for the two given objects. 

3500 

3501 Default logic simply deletes the "removing" record. Subclass 

3502 can override as needed for custom logic. 

3503 

3504 See also :meth:`merge_validate_and_execute()`. 

3505 

3506 :param removing: Reference to the "removing" model instance/record. 

3507 

3508 :param keeping: Reference to the "keeping" model instance/record. 

3509 """ 

3510 session = self.Session() 

3511 

3512 # nb. default "merge" does not update kept object! 

3513 session.delete(removing) 

3514 

3515 ############################## 

3516 # row methods 

3517 ############################## 

3518 

3519 def get_rows_title(self): 

3520 """ 

3521 Returns the display title for model **rows** grid, if 

3522 applicable/desired. Only relevant if :attr:`has_rows` is 

3523 true. 

3524 

3525 There is no default here, but subclass may override by 

3526 assigning :attr:`rows_title`. 

3527 """ 

3528 if hasattr(self, "rows_title"): 

3529 return self.rows_title 

3530 return self.get_row_model_title_plural() 

3531 

3532 def get_row_parent(self, row): 

3533 """ 

3534 This must return the parent object for the given child row. 

3535 Only relevant if :attr:`has_rows` is true. 

3536 

3537 Default logic is not implemented; subclass must override. 

3538 """ 

3539 raise NotImplementedError 

3540 

3541 def make_row_model_grid(self, obj, **kwargs): 

3542 """ 

3543 Create and return a grid for a record's **rows** data, for use 

3544 in :meth:`view()`. Only applicable if :attr:`has_rows` is 

3545 true. 

3546 

3547 :param obj: Current model instance for which rows data is 

3548 being displayed. 

3549 

3550 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the 

3551 rows data. 

3552 

3553 See also related methods, which are called by this one: 

3554 

3555 * :meth:`get_row_grid_key()` 

3556 * :meth:`get_row_grid_columns()` 

3557 * :meth:`get_row_grid_data()` 

3558 * :meth:`configure_row_grid()` 

3559 """ 

3560 if "key" not in kwargs: 

3561 kwargs["key"] = self.get_row_grid_key() 

3562 

3563 if "model_class" not in kwargs: 

3564 model_class = self.get_row_model_class() 

3565 if model_class: 

3566 kwargs["model_class"] = model_class 

3567 

3568 if "columns" not in kwargs: 

3569 kwargs["columns"] = self.get_row_grid_columns() 

3570 

3571 if "data" not in kwargs: 

3572 kwargs["data"] = self.get_row_grid_data(obj) 

3573 

3574 kwargs.setdefault("filterable", self.rows_filterable) 

3575 kwargs.setdefault("filter_defaults", self.rows_filter_defaults) 

3576 kwargs.setdefault("sortable", self.rows_sortable) 

3577 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend) 

3578 kwargs.setdefault("sort_defaults", self.rows_sort_defaults) 

3579 kwargs.setdefault("paginated", self.rows_paginated) 

3580 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend) 

3581 

3582 if "actions" not in kwargs: 

3583 actions = [] 

3584 

3585 if self.rows_viewable: 

3586 actions.append( 

3587 self.make_grid_action( 

3588 "view", icon="eye", url=self.get_row_action_url_view 

3589 ) 

3590 ) 

3591 

3592 if actions: 

3593 kwargs["actions"] = actions 

3594 

3595 grid = self.make_grid(**kwargs) 

3596 self.configure_row_grid(grid) 

3597 grid.load_settings() 

3598 return grid 

3599 

3600 def get_row_grid_key(self): 

3601 """ 

3602 Returns the (presumably) unique key to be used for the 

3603 **rows** grid in :meth:`view()`. Only relevant if 

3604 :attr:`has_rows` is true. 

3605 

3606 This is called from :meth:`make_row_model_grid()`; in the 

3607 resulting grid, this becomes 

3608 :attr:`~wuttaweb.grids.base.Grid.key`. 

3609 

3610 Whereas you can define :attr:`grid_key` for the main grid, the 

3611 row grid key is always generated dynamically. This 

3612 incorporates the current record key (whose rows are in the 

3613 grid) so that the rows grid for each record is unique. 

3614 """ 

3615 parts = [self.get_grid_key()] 

3616 for key in self.get_model_key(): 

3617 parts.append(str(self.request.matchdict[key])) 

3618 return ".".join(parts) 

3619 

3620 def get_row_grid_columns(self): 

3621 """ 

3622 Returns the default list of column names for the **rows** 

3623 grid, for use in :meth:`view()`. Only relevant if 

3624 :attr:`has_rows` is true. 

3625 

3626 This is called by :meth:`make_row_model_grid()`; in the 

3627 resulting grid, this becomes 

3628 :attr:`~wuttaweb.grids.base.Grid.columns`. 

3629 

3630 This method may return ``None``, in which case the grid may 

3631 (try to) generate its own default list. 

3632 

3633 Subclass may define :attr:`row_grid_columns` for simple cases, 

3634 or can override this method if needed. 

3635 

3636 Also note that :meth:`configure_row_grid()` may be used to 

3637 further modify the final column set, regardless of what this 

3638 method returns. So a common pattern is to declare all 

3639 "supported" columns by setting :attr:`row_grid_columns` but 

3640 then optionally remove or replace some of those within 

3641 :meth:`configure_row_grid()`. 

3642 """ 

3643 if hasattr(self, "row_grid_columns"): 

3644 return self.row_grid_columns 

3645 return None 

3646 

3647 def get_row_grid_data(self, obj): 

3648 """ 

3649 Returns the data for the **rows** grid, for use in 

3650 :meth:`view()`. Only relevant if :attr:`has_rows` is true. 

3651 

3652 This is called by :meth:`make_row_model_grid()`; in the 

3653 resulting grid, this becomes 

3654 :attr:`~wuttaweb.grids.base.Grid.data`. 

3655 

3656 Default logic not implemented; subclass must define this. 

3657 """ 

3658 raise NotImplementedError 

3659 

3660 def configure_row_grid(self, grid): 

3661 """ 

3662 Configure the **rows** grid for use in :meth:`view()`. Only 

3663 relevant if :attr:`has_rows` is true. 

3664 

3665 This is called by :meth:`make_row_model_grid()`. 

3666 

3667 There is minimal default logic here; subclass should override 

3668 as needed. The ``grid`` param will already be "complete" and 

3669 ready to use as-is, but this method can further modify it 

3670 based on request details etc. 

3671 """ 

3672 grid.remove("uuid") 

3673 self.set_row_labels(grid) 

3674 

3675 def set_row_labels(self, obj): 

3676 """ 

3677 Set label overrides on a **row** form or grid, based on what 

3678 is defined by the view class and its parent class(es). 

3679 

3680 This is called automatically from 

3681 :meth:`configure_row_grid()` and 

3682 :meth:`configure_row_form()`. 

3683 

3684 This calls :meth:`collect_row_labels()` to find everything, 

3685 then it assigns the labels using one of (based on ``obj`` 

3686 type): 

3687 

3688 * :func:`wuttaweb.forms.base.Form.set_label()` 

3689 * :func:`wuttaweb.grids.base.Grid.set_label()` 

3690 

3691 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a 

3692 :class:`~wuttaweb.forms.base.Form` instance. 

3693 """ 

3694 labels = self.collect_row_labels() 

3695 for key, label in labels.items(): 

3696 obj.set_label(key, label) 

3697 

3698 def collect_row_labels(self): 

3699 """ 

3700 Collect all **row** labels defined within the view class 

3701 hierarchy. 

3702 

3703 This is called by :meth:`set_row_labels()`. 

3704 

3705 :returns: Dict of all labels found. 

3706 """ 

3707 labels = {} 

3708 hierarchy = self.get_class_hierarchy() 

3709 for cls in hierarchy: 

3710 if hasattr(cls, "row_labels"): 

3711 labels.update(cls.row_labels) 

3712 return labels 

3713 

3714 def get_row_action_url_view(self, row, i): 

3715 """ 

3716 Must return the "view" action url for the given row object. 

3717 

3718 Only relevant if :attr:`rows_viewable` is true. 

3719 

3720 There is no default logic; subclass must override if needed. 

3721 """ 

3722 raise NotImplementedError 

3723 

3724 def create_row(self): 

3725 """ 

3726 View to create a new "child row" record. 

3727 

3728 This usually corresponds to a URL like ``/widgets/XXX/new-row``. 

3729 

3730 By default, this view is included only if 

3731 :attr:`rows_creatable` is true. 

3732 

3733 The default "create row" view logic will show a form with 

3734 field widgets, allowing user to submit new values which are 

3735 then persisted to the DB (assuming typical SQLAlchemy model). 

3736 

3737 Subclass normally should not override this method, but rather 

3738 one of the related methods which are called (in)directly by 

3739 this one: 

3740 

3741 * :meth:`make_row_model_form()` 

3742 * :meth:`configure_row_form()` 

3743 * :meth:`create_row_save_form()` 

3744 * :meth:`redirect_after_create_row()` 

3745 """ 

3746 self.creating = True 

3747 parent = self.get_instance() 

3748 parent_url = self.get_action_url("view", parent) 

3749 

3750 form = self.make_row_model_form(cancel_url_fallback=parent_url) 

3751 if form.validate(): 

3752 result = self.create_row_save_form(form) 

3753 return self.redirect_after_create_row(result) 

3754 

3755 index_link = tags.link_to(self.get_index_title(), self.get_index_url()) 

3756 parent_link = tags.link_to(self.get_instance_title(parent), parent_url) 

3757 index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( 

3758 [index_link, parent_link] 

3759 ) 

3760 

3761 context = { 

3762 "form": form, 

3763 "index_title_rendered": index_title_rendered, 

3764 "row_model_title": self.get_row_model_title(), 

3765 } 

3766 return self.render_to_response("create_row", context) 

3767 

3768 def create_row_save_form(self, form): 

3769 """ 

3770 This method converts the validated form data to a row model 

3771 instance, and then saves the result to DB. It is called by 

3772 :meth:`create_row()`. 

3773 

3774 :returns: The resulting row model instance, as produced by 

3775 :meth:`objectify()`. 

3776 """ 

3777 row = self.objectify(form) 

3778 session = self.Session() 

3779 session.add(row) 

3780 session.flush() 

3781 return row 

3782 

3783 def redirect_after_create_row(self, row): 

3784 """ 

3785 Returns a redirect to the "view parent" page relative to the 

3786 given newly-created row. Subclass may override as needed. 

3787 

3788 This is called by :meth:`create_row()`. 

3789 """ 

3790 parent = self.get_row_parent(row) 

3791 return self.redirect(self.get_action_url("view", parent)) 

3792 

3793 def make_row_model_form(self, model_instance=None, **kwargs): 

3794 """ 

3795 Create and return a form for the row model. 

3796 

3797 This is called by :meth:`create_row()`. 

3798 

3799 See also related methods, which are called by this one: 

3800 

3801 * :meth:`get_row_model_class()` 

3802 * :meth:`get_row_form_fields()` 

3803 * :meth:`~wuttaweb.views.base.View.make_form()` 

3804 * :meth:`configure_row_form()` 

3805 

3806 :returns: :class:`~wuttaweb.forms.base.Form` instance 

3807 """ 

3808 if "model_class" not in kwargs: 

3809 model_class = self.get_row_model_class() 

3810 if model_class: 

3811 kwargs["model_class"] = model_class 

3812 

3813 kwargs["model_instance"] = model_instance 

3814 

3815 if not kwargs.get("fields"): 

3816 fields = self.get_row_form_fields() 

3817 if fields: 

3818 kwargs["fields"] = fields 

3819 

3820 form = self.make_form(**kwargs) 

3821 self.configure_row_form(form) 

3822 return form 

3823 

3824 def get_row_form_fields(self): 

3825 """ 

3826 Returns the initial list of field names for the row model 

3827 form. 

3828 

3829 This is called by :meth:`make_row_model_form()`; in the 

3830 resulting :class:`~wuttaweb.forms.base.Form` instance, this 

3831 becomes :attr:`~wuttaweb.forms.base.Form.fields`. 

3832 

3833 This method may return ``None``, in which case the form may 

3834 (try to) generate its own default list. 

3835 

3836 Subclass may define :attr:`row_form_fields` for simple cases, 

3837 or can override this method if needed. 

3838 

3839 Note that :meth:`configure_row_form()` may be used to further 

3840 modify the final field list, regardless of what this method 

3841 returns. So a common pattern is to declare all "supported" 

3842 fields by setting :attr:`row_form_fields` but then optionally 

3843 remove or replace some in :meth:`configure_row_form()`. 

3844 """ 

3845 if hasattr(self, "row_form_fields"): 

3846 return self.row_form_fields 

3847 return None 

3848 

3849 def configure_row_form(self, form): 

3850 """ 

3851 Configure the row model form. 

3852 

3853 This is called by :meth:`make_row_model_form()` - for multiple 

3854 CRUD views (create, view, edit, delete, possibly others). 

3855 

3856 The ``form`` param will already be "complete" and ready to use 

3857 as-is, but this method can further modify it based on request 

3858 details etc. 

3859 

3860 Subclass can override as needed, although be sure to invoke 

3861 this parent method via ``super()`` if so. 

3862 """ 

3863 form.remove("uuid") 

3864 self.set_row_labels(form) 

3865 

3866 ############################## 

3867 # class methods 

3868 ############################## 

3869 

3870 @classmethod 

3871 def get_model_class(cls): 

3872 """ 

3873 Returns the model class for the view (if defined). 

3874 

3875 A model class will *usually* be a SQLAlchemy mapped class, 

3876 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 

3877 

3878 There is no default value here, but a subclass may override by 

3879 assigning :attr:`model_class`. 

3880 

3881 Note that the model class is not *required* - however if you 

3882 do not set the :attr:`model_class`, then you *must* set the 

3883 :attr:`model_name`. 

3884 """ 

3885 return cls.model_class 

3886 

3887 @classmethod 

3888 def get_model_name(cls): 

3889 """ 

3890 Returns the model name for the view. 

3891 

3892 A model name should generally be in the format of a Python 

3893 class name, e.g. ``'WuttaWidget'``. (Note this is 

3894 *singular*, not plural.) 

3895 

3896 The default logic will call :meth:`get_model_class()` and 

3897 return that class name as-is. A subclass may override by 

3898 assigning :attr:`model_name`. 

3899 """ 

3900 if hasattr(cls, "model_name"): 

3901 return cls.model_name 

3902 

3903 return cls.get_model_class().__name__ 

3904 

3905 @classmethod 

3906 def get_model_name_normalized(cls): 

3907 """ 

3908 Returns the "normalized" model name for the view. 

3909 

3910 A normalized model name should generally be in the format of a 

3911 Python variable name, e.g. ``'wutta_widget'``. (Note this is 

3912 *singular*, not plural.) 

3913 

3914 The default logic will call :meth:`get_model_name()` and 

3915 simply lower-case the result. A subclass may override by 

3916 assigning :attr:`model_name_normalized`. 

3917 """ 

3918 if hasattr(cls, "model_name_normalized"): 

3919 return cls.model_name_normalized 

3920 

3921 return cls.get_model_name().lower() 

3922 

3923 @classmethod 

3924 def get_model_title(cls): 

3925 """ 

3926 Returns the "humanized" (singular) model title for the view. 

3927 

3928 The model title will be displayed to the user, so should have 

3929 proper grammar and capitalization, e.g. ``"Wutta Widget"``. 

3930 (Note this is *singular*, not plural.) 

3931 

3932 The default logic will call :meth:`get_model_name()` and use 

3933 the result as-is. A subclass may override by assigning 

3934 :attr:`model_title`. 

3935 """ 

3936 if hasattr(cls, "model_title"): 

3937 return cls.model_title 

3938 

3939 if model_class := cls.get_model_class(): 

3940 if hasattr(model_class, "__wutta_hint__"): 

3941 if model_title := model_class.__wutta_hint__.get("model_title"): 

3942 return model_title 

3943 

3944 return cls.get_model_name() 

3945 

3946 @classmethod 

3947 def get_model_title_plural(cls): 

3948 """ 

3949 Returns the "humanized" (plural) model title for the view. 

3950 

3951 The model title will be displayed to the user, so should have 

3952 proper grammar and capitalization, e.g. ``"Wutta Widgets"``. 

3953 (Note this is *plural*, not singular.) 

3954 

3955 The default logic will call :meth:`get_model_title()` and 

3956 simply add a ``'s'`` to the end. A subclass may override by 

3957 assigning :attr:`model_title_plural`. 

3958 """ 

3959 if hasattr(cls, "model_title_plural"): 

3960 return cls.model_title_plural 

3961 

3962 if model_class := cls.get_model_class(): 

3963 if hasattr(model_class, "__wutta_hint__"): 

3964 if model_title_plural := model_class.__wutta_hint__.get( 

3965 "model_title_plural" 

3966 ): 

3967 return model_title_plural 

3968 

3969 model_title = cls.get_model_title() 

3970 return f"{model_title}s" 

3971 

3972 @classmethod 

3973 def get_model_key(cls): 

3974 """ 

3975 Returns the "model key" for the master view. 

3976 

3977 This should return a tuple containing one or more "field 

3978 names" corresponding to the primary key for data records. 

3979 

3980 In the most simple/common scenario, where the master view 

3981 represents a Wutta-based SQLAlchemy model, the return value 

3982 for this method is: ``('uuid',)`` 

3983 

3984 Any class mapped via SQLAlchemy should be supported 

3985 automatically, the keys are determined from class inspection. 

3986 

3987 But there is no "sane" default for other scenarios, in which 

3988 case subclass should define :attr:`model_key`. If the model 

3989 key cannot be determined, raises ``AttributeError``. 

3990 

3991 :returns: Tuple of field names comprising the model key. 

3992 """ 

3993 if hasattr(cls, "model_key"): 

3994 keys = cls.model_key 

3995 if isinstance(keys, str): 

3996 keys = [keys] 

3997 return tuple(keys) 

3998 

3999 model_class = cls.get_model_class() 

4000 if model_class: 

4001 # nb. we want the primary key but must avoid column names 

4002 # in case mapped class uses different prop keys 

4003 inspector = sa.inspect(model_class) 

4004 keys = [col.name for col in inspector.primary_key] 

4005 return tuple( 

4006 prop.key 

4007 for prop in inspector.column_attrs 

4008 if all(col.name in keys for col in prop.columns) 

4009 ) 

4010 

4011 raise AttributeError(f"you must define model_key for view class: {cls}") 

4012 

4013 @classmethod 

4014 def get_route_prefix(cls): 

4015 """ 

4016 Returns the "route prefix" for the master view. This prefix 

4017 is used for all named routes defined by the view class. 

4018 

4019 For instance if route prefix is ``'widgets'`` then a view 

4020 might have these routes: 

4021 

4022 * ``'widgets'`` 

4023 * ``'widgets.create'`` 

4024 * ``'widgets.edit'`` 

4025 * ``'widgets.delete'`` 

4026 

4027 The default logic will call 

4028 :meth:`get_model_name_normalized()` and simply add an ``'s'`` 

4029 to the end, making it plural. A subclass may override by 

4030 assigning :attr:`route_prefix`. 

4031 """ 

4032 if hasattr(cls, "route_prefix"): 

4033 return cls.route_prefix 

4034 

4035 model_name = cls.get_model_name_normalized() 

4036 return f"{model_name}s" 

4037 

4038 @classmethod 

4039 def get_permission_prefix(cls): 

4040 """ 

4041 Returns the "permission prefix" for the master view. This 

4042 prefix is used for all permissions defined by the view class. 

4043 

4044 For instance if permission prefix is ``'widgets'`` then a view 

4045 might have these permissions: 

4046 

4047 * ``'widgets.list'`` 

4048 * ``'widgets.create'`` 

4049 * ``'widgets.edit'`` 

4050 * ``'widgets.delete'`` 

4051 

4052 The default logic will call :meth:`get_route_prefix()` and use 

4053 that value as-is. A subclass may override by assigning 

4054 :attr:`permission_prefix`. 

4055 """ 

4056 if hasattr(cls, "permission_prefix"): 

4057 return cls.permission_prefix 

4058 

4059 return cls.get_route_prefix() 

4060 

4061 @classmethod 

4062 def get_url_prefix(cls): 

4063 """ 

4064 Returns the "URL prefix" for the master view. This prefix is 

4065 used for all URLs defined by the view class. 

4066 

4067 Using the same example as in :meth:`get_route_prefix()`, the 

4068 URL prefix would be ``'/widgets'`` and the view would have 

4069 defined routes for these URLs: 

4070 

4071 * ``/widgets/`` 

4072 * ``/widgets/new`` 

4073 * ``/widgets/XXX/edit`` 

4074 * ``/widgets/XXX/delete`` 

4075 

4076 The default logic will call :meth:`get_route_prefix()` and 

4077 simply add a ``'/'`` to the beginning. A subclass may 

4078 override by assigning :attr:`url_prefix`. 

4079 """ 

4080 if hasattr(cls, "url_prefix"): 

4081 return cls.url_prefix 

4082 

4083 route_prefix = cls.get_route_prefix() 

4084 return f"/{route_prefix}" 

4085 

4086 @classmethod 

4087 def get_instance_url_prefix(cls): 

4088 """ 

4089 Generate the URL prefix specific to an instance for this model 

4090 view. This will include model key param placeholders; it 

4091 winds up looking like: 

4092 

4093 * ``/widgets/{uuid}`` 

4094 * ``/resources/{foo}|{bar}|{baz}`` 

4095 

4096 The former being the most simple/common, and the latter 

4097 showing what a "composite" model key looks like, with pipe 

4098 symbols separating the key parts. 

4099 """ 

4100 prefix = cls.get_url_prefix() + "/" 

4101 for i, key in enumerate(cls.get_model_key()): 

4102 if i: 

4103 prefix += "|" 

4104 prefix += f"{{{key}}}" 

4105 return prefix 

4106 

4107 @classmethod 

4108 def get_template_prefix(cls): 

4109 """ 

4110 Returns the "template prefix" for the master view. This 

4111 prefix is used to guess which template path to render for a 

4112 given view. 

4113 

4114 Using the same example as in :meth:`get_url_prefix()`, the 

4115 template prefix would also be ``'/widgets'`` and the templates 

4116 assumed for those routes would be: 

4117 

4118 * ``/widgets/index.mako`` 

4119 * ``/widgets/create.mako`` 

4120 * ``/widgets/edit.mako`` 

4121 * ``/widgets/delete.mako`` 

4122 

4123 The default logic will call :meth:`get_url_prefix()` and 

4124 return that value as-is. A subclass may override by assigning 

4125 :attr:`template_prefix`. 

4126 """ 

4127 if hasattr(cls, "template_prefix"): 

4128 return cls.template_prefix 

4129 

4130 return cls.get_url_prefix() 

4131 

4132 @classmethod 

4133 def get_grid_key(cls): 

4134 """ 

4135 Returns the (presumably) unique key to be used for the primary 

4136 grid in the :meth:`index()` view. This key may also be used 

4137 as the basis (key prefix) for secondary grids. 

4138 

4139 This is called from :meth:`make_model_grid()`; in the 

4140 resulting :class:`~wuttaweb.grids.base.Grid` instance, this 

4141 becomes :attr:`~wuttaweb.grids.base.Grid.key`. 

4142 

4143 The default logic for this method will call 

4144 :meth:`get_route_prefix()` and return that value as-is. A 

4145 subclass may override by assigning :attr:`grid_key`. 

4146 """ 

4147 if hasattr(cls, "grid_key"): 

4148 return cls.grid_key 

4149 

4150 return cls.get_route_prefix() 

4151 

4152 @classmethod 

4153 def get_config_title(cls): 

4154 """ 

4155 Returns the "config title" for the view/model. 

4156 

4157 The config title is used for page title in the 

4158 :meth:`configure()` view, as well as links to it. It is 

4159 usually plural, e.g. ``"Wutta Widgets"`` in which case that 

4160 winds up being displayed in the web app as: **Configure Wutta 

4161 Widgets** 

4162 

4163 The default logic will call :meth:`get_model_title_plural()` 

4164 and return that as-is. A subclass may override by assigning 

4165 :attr:`config_title`. 

4166 """ 

4167 if hasattr(cls, "config_title"): 

4168 return cls.config_title 

4169 

4170 return cls.get_model_title_plural() 

4171 

4172 @classmethod 

4173 def get_row_model_class(cls): 

4174 """ 

4175 Returns the "child row" model class for the view. Only 

4176 relevant if :attr:`has_rows` is true. 

4177 

4178 Default logic returns the :attr:`row_model_class` reference. 

4179 

4180 :returns: Mapped class, or ``None`` 

4181 """ 

4182 return cls.row_model_class 

4183 

4184 @classmethod 

4185 def get_row_model_name(cls): 

4186 """ 

4187 Returns the row model name for the view. 

4188 

4189 A model name should generally be in the format of a Python 

4190 class name, e.g. ``'BatchRow'``. (Note this is *singular*, 

4191 not plural.) 

4192 

4193 The default logic will call :meth:`get_row_model_class()` and 

4194 return that class name as-is. Subclass may override by 

4195 assigning :attr:`row_model_name`. 

4196 """ 

4197 if hasattr(cls, "row_model_name"): 

4198 return cls.row_model_name 

4199 

4200 return cls.get_row_model_class().__name__ 

4201 

4202 @classmethod 

4203 def get_row_model_title(cls): 

4204 """ 

4205 Returns the "humanized" (singular) title for the row model. 

4206 

4207 The model title will be displayed to the user, so should have 

4208 proper grammar and capitalization, e.g. ``"Batch Row"``. 

4209 (Note this is *singular*, not plural.) 

4210 

4211 The default logic will call :meth:`get_row_model_name()` and 

4212 use the result as-is. Subclass may override by assigning 

4213 :attr:`row_model_title`. 

4214 

4215 See also :meth:`get_row_model_title_plural()`. 

4216 """ 

4217 if hasattr(cls, "row_model_title"): 

4218 return cls.row_model_title 

4219 

4220 if model_class := cls.get_row_model_class(): 

4221 if hasattr(model_class, "__wutta_hint__"): 

4222 if model_title := model_class.__wutta_hint__.get("model_title"): 

4223 return model_title 

4224 

4225 return cls.get_row_model_name() 

4226 

4227 @classmethod 

4228 def get_row_model_title_plural(cls): 

4229 """ 

4230 Returns the "humanized" (plural) title for the row model. 

4231 

4232 The model title will be displayed to the user, so should have 

4233 proper grammar and capitalization, e.g. ``"Batch Rows"``. 

4234 (Note this is *plural*, not singular.) 

4235 

4236 The default logic will call :meth:`get_row_model_title()` and 

4237 simply add a ``'s'`` to the end. Subclass may override by 

4238 assigning :attr:`row_model_title_plural`. 

4239 """ 

4240 if hasattr(cls, "row_model_title_plural"): 

4241 return cls.row_model_title_plural 

4242 

4243 if model_class := cls.get_row_model_class(): 

4244 if hasattr(model_class, "__wutta_hint__"): 

4245 if model_title_plural := model_class.__wutta_hint__.get( 

4246 "model_title_plural" 

4247 ): 

4248 return model_title_plural 

4249 

4250 row_model_title = cls.get_row_model_title() 

4251 return f"{row_model_title}s" 

4252 

4253 ############################## 

4254 # configuration 

4255 ############################## 

4256 

4257 @classmethod 

4258 def defaults(cls, config): 

4259 """ 

4260 Provide default Pyramid configuration for a master view. 

4261 

4262 This is generally called from within the module's 

4263 ``includeme()`` function, e.g.:: 

4264 

4265 from wuttaweb.views import MasterView 

4266 

4267 class WidgetView(MasterView): 

4268 model_name = 'Widget' 

4269 

4270 def includeme(config): 

4271 WidgetView.defaults(config) 

4272 

4273 :param config: Reference to the app's 

4274 :class:`pyramid:pyramid.config.Configurator` instance. 

4275 """ 

4276 cls._defaults(config) 

4277 

4278 @classmethod 

4279 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches 

4280 wutta_config = config.registry.settings.get("wutta_config") 

4281 app = wutta_config.get_app() 

4282 

4283 route_prefix = cls.get_route_prefix() 

4284 permission_prefix = cls.get_permission_prefix() 

4285 url_prefix = cls.get_url_prefix() 

4286 model_title = cls.get_model_title() 

4287 model_title_plural = cls.get_model_title_plural() 

4288 

4289 # add to master view registry 

4290 config.add_wutta_master_view(cls) 

4291 

4292 # permission group 

4293 config.add_wutta_permission_group( 

4294 permission_prefix, model_title_plural, overwrite=False 

4295 ) 

4296 

4297 # index 

4298 if cls.listable: 

4299 config.add_route(route_prefix, f"{url_prefix}/") 

4300 config.add_view( 

4301 cls, 

4302 attr="index", 

4303 route_name=route_prefix, 

4304 permission=f"{permission_prefix}.list", 

4305 ) 

4306 config.add_wutta_permission( 

4307 permission_prefix, 

4308 f"{permission_prefix}.list", 

4309 f"Browse / search {model_title_plural}", 

4310 ) 

4311 

4312 # create 

4313 if cls.creatable: 

4314 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new") 

4315 config.add_view( 

4316 cls, 

4317 attr="create", 

4318 route_name=f"{route_prefix}.create", 

4319 permission=f"{permission_prefix}.create", 

4320 ) 

4321 config.add_wutta_permission( 

4322 permission_prefix, 

4323 f"{permission_prefix}.create", 

4324 f"Create new {model_title}", 

4325 ) 

4326 

4327 # edit 

4328 if cls.editable: 

4329 instance_url_prefix = cls.get_instance_url_prefix() 

4330 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit") 

4331 config.add_view( 

4332 cls, 

4333 attr="edit", 

4334 route_name=f"{route_prefix}.edit", 

4335 permission=f"{permission_prefix}.edit", 

4336 ) 

4337 config.add_wutta_permission( 

4338 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}" 

4339 ) 

4340 

4341 # delete 

4342 if cls.deletable: 

4343 instance_url_prefix = cls.get_instance_url_prefix() 

4344 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete") 

4345 config.add_view( 

4346 cls, 

4347 attr="delete", 

4348 route_name=f"{route_prefix}.delete", 

4349 permission=f"{permission_prefix}.delete", 

4350 ) 

4351 config.add_wutta_permission( 

4352 permission_prefix, 

4353 f"{permission_prefix}.delete", 

4354 f"Delete {model_title}", 

4355 ) 

4356 

4357 # bulk delete 

4358 if cls.deletable_bulk: 

4359 config.add_route( 

4360 f"{route_prefix}.delete_bulk", 

4361 f"{url_prefix}/delete-bulk", 

4362 request_method="POST", 

4363 ) 

4364 config.add_view( 

4365 cls, 

4366 attr="delete_bulk", 

4367 route_name=f"{route_prefix}.delete_bulk", 

4368 permission=f"{permission_prefix}.delete_bulk", 

4369 ) 

4370 config.add_wutta_permission( 

4371 permission_prefix, 

4372 f"{permission_prefix}.delete_bulk", 

4373 f"Delete {model_title_plural} in bulk", 

4374 ) 

4375 

4376 # merge 

4377 if cls.mergeable: 

4378 config.add_wutta_permission( 

4379 permission_prefix, 

4380 f"{permission_prefix}.merge", 

4381 f"Merge 2 {model_title_plural}", 

4382 ) 

4383 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge") 

4384 config.add_view( 

4385 cls, 

4386 attr="merge", 

4387 route_name=f"{route_prefix}.merge", 

4388 permission=f"{permission_prefix}.merge", 

4389 ) 

4390 

4391 # autocomplete 

4392 if cls.has_autocomplete: 

4393 config.add_route( 

4394 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete" 

4395 ) 

4396 config.add_view( 

4397 cls, 

4398 attr="autocomplete", 

4399 route_name=f"{route_prefix}.autocomplete", 

4400 renderer="json", 

4401 permission=f"{route_prefix}.list", 

4402 ) 

4403 

4404 # download 

4405 if cls.downloadable: 

4406 instance_url_prefix = cls.get_instance_url_prefix() 

4407 config.add_route( 

4408 f"{route_prefix}.download", f"{instance_url_prefix}/download" 

4409 ) 

4410 config.add_view( 

4411 cls, 

4412 attr="download", 

4413 route_name=f"{route_prefix}.download", 

4414 permission=f"{permission_prefix}.download", 

4415 ) 

4416 config.add_wutta_permission( 

4417 permission_prefix, 

4418 f"{permission_prefix}.download", 

4419 f"Download file(s) for {model_title}", 

4420 ) 

4421 

4422 # execute 

4423 if cls.executable: 

4424 instance_url_prefix = cls.get_instance_url_prefix() 

4425 config.add_route( 

4426 f"{route_prefix}.execute", 

4427 f"{instance_url_prefix}/execute", 

4428 request_method="POST", 

4429 ) 

4430 config.add_view( 

4431 cls, 

4432 attr="execute", 

4433 route_name=f"{route_prefix}.execute", 

4434 permission=f"{permission_prefix}.execute", 

4435 ) 

4436 config.add_wutta_permission( 

4437 permission_prefix, 

4438 f"{permission_prefix}.execute", 

4439 f"Execute {model_title}", 

4440 ) 

4441 

4442 # configure 

4443 if cls.configurable: 

4444 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure") 

4445 config.add_view( 

4446 cls, 

4447 attr="configure", 

4448 route_name=f"{route_prefix}.configure", 

4449 permission=f"{permission_prefix}.configure", 

4450 ) 

4451 config.add_wutta_permission( 

4452 permission_prefix, 

4453 f"{permission_prefix}.configure", 

4454 f"Configure {model_title_plural}", 

4455 ) 

4456 

4457 # view 

4458 # nb. always register this one last, so it does not take 

4459 # priority over model-wide action routes, e.g. delete_bulk 

4460 if cls.viewable: 

4461 instance_url_prefix = cls.get_instance_url_prefix() 

4462 config.add_route(f"{route_prefix}.view", instance_url_prefix) 

4463 config.add_view( 

4464 cls, 

4465 attr="view", 

4466 route_name=f"{route_prefix}.view", 

4467 permission=f"{permission_prefix}.view", 

4468 ) 

4469 config.add_wutta_permission( 

4470 permission_prefix, f"{permission_prefix}.view", f"View {model_title}" 

4471 ) 

4472 

4473 # version history 

4474 if cls.is_versioned() and app.continuum_is_enabled(): 

4475 instance_url_prefix = cls.get_instance_url_prefix() 

4476 config.add_wutta_permission( 

4477 permission_prefix, 

4478 f"{permission_prefix}.versions", 

4479 f"View version history for {model_title}", 

4480 ) 

4481 config.add_route( 

4482 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/" 

4483 ) 

4484 config.add_view( 

4485 cls, 

4486 attr="view_versions", 

4487 route_name=f"{route_prefix}.versions", 

4488 permission=f"{permission_prefix}.versions", 

4489 ) 

4490 config.add_route( 

4491 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}" 

4492 ) 

4493 config.add_view( 

4494 cls, 

4495 attr="view_version", 

4496 route_name=f"{route_prefix}.version", 

4497 permission=f"{permission_prefix}.versions", 

4498 ) 

4499 

4500 ############################## 

4501 # row-specific routes 

4502 ############################## 

4503 

4504 # create row 

4505 if cls.has_rows and cls.rows_creatable: 

4506 config.add_wutta_permission( 

4507 permission_prefix, 

4508 f"{permission_prefix}.create_row", 

4509 f'Create new "rows" for {model_title}', 

4510 ) 

4511 config.add_route( 

4512 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row" 

4513 ) 

4514 config.add_view( 

4515 cls, 

4516 attr="create_row", 

4517 route_name=f"{route_prefix}.create_row", 

4518 permission=f"{permission_prefix}.create_row", 

4519 )