Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/master.py: 100%
1359 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 13:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 13:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base Logic for Master Views
25"""
27# pylint: disable=too-many-lines
29import logging
30import os
31import threading
32import warnings
33from uuid import UUID
35import sqlalchemy as sa
36from sqlalchemy import orm
38from pyramid.renderers import render_to_response
39from webhelpers2.html import HTML, tags
41from wuttjamaican.util import get_class_hierarchy
42from wuttaweb.views.base import View
43from wuttaweb.util import get_form_data, render_csrf_token
44from wuttaweb.db import Session
45from wuttaweb.progress import SessionProgress
46from wuttaweb.diffs import MergeDiff, VersionDiff
48log = logging.getLogger(__name__)
51class MasterView(View): # pylint: disable=too-many-public-methods
52 """
53 Base class for "master" views.
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.
59 Many attributes may be overridden in subclass. For instance to
60 define :attr:`model_class`::
62 from wuttaweb.views import MasterView
63 from wuttjamaican.db.model import Person
65 class MyPersonView(MasterView):
66 model_class = Person
68 def includeme(config):
69 MyPersonView.defaults(config)
71 .. note::
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.
78 .. attribute:: Session
80 Reference to the view's :term:`db session` factory. By default
81 this will correspond to the :term:`app database` but a subclass
82 is free to use a different one where needed.
84 The built-in default is a
85 :class:`~sqlalchemy:sqlalchemy.orm.scoped_session` instance.
86 (Actually it's a reference to
87 :class:`~wuttaweb.db.sess.Session`.) Code may reference it
88 with or without "calling" it per se, although explicitly
89 calling it is preferred::
91 def some_view(self):
92 model = self.app.model
94 # referencing as session instance
95 widgets = self.Session.query(model.Widget).all()
97 # calling as factory
98 session = self.Session()
99 widgets = session.query(model.Widget).all()
101 .. attribute:: model_class
103 Optional reference to a :term:`data model` class. While not
104 strictly required, most views will set this to a SQLAlchemy
105 mapped class,
106 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
108 Code should not access this directly but instead call
109 :meth:`get_model_class()`.
111 .. attribute:: model_name
113 Optional override for the view's data model name,
114 e.g. ``'WuttaWidget'``.
116 Code should not access this directly but instead call
117 :meth:`get_model_name()`.
119 .. attribute:: model_name_normalized
121 Optional override for the view's "normalized" data model name,
122 e.g. ``'wutta_widget'``.
124 Code should not access this directly but instead call
125 :meth:`get_model_name_normalized()`.
127 .. attribute:: model_title
129 Optional override for the view's "humanized" (singular) model
130 title, e.g. ``"Wutta Widget"``.
132 Code should not access this directly but instead call
133 :meth:`get_model_title()`.
135 .. attribute:: model_title_plural
137 Optional override for the view's "humanized" (plural) model
138 title, e.g. ``"Wutta Widgets"``.
140 Code should not access this directly but instead call
141 :meth:`get_model_title_plural()`.
143 .. attribute:: model_key
145 Optional override for the view's "model key" - e.g. ``'id'``
146 (string for simple case) or composite key such as
147 ``('id_field', 'name_field')``.
149 If :attr:`model_class` is set to a SQLAlchemy mapped class, the
150 model key can be determined automatically.
152 Code should not access this directly but instead call
153 :meth:`get_model_key()`.
155 .. attribute:: grid_key
157 Optional override for the view's grid key, e.g. ``'widgets'``.
159 Code should not access this directly but instead call
160 :meth:`get_grid_key()`.
162 .. attribute:: config_title
164 Optional override for the view's "config" title, e.g. ``"Wutta
165 Widgets"`` (to be displayed as **Configure Wutta Widgets**).
167 Code should not access this directly but instead call
168 :meth:`get_config_title()`.
170 .. attribute:: route_prefix
172 Optional override for the view's route prefix,
173 e.g. ``'wutta_widgets'``.
175 Code should not access this directly but instead call
176 :meth:`get_route_prefix()`.
178 .. attribute:: permission_prefix
180 Optional override for the view's permission prefix,
181 e.g. ``'wutta_widgets'``.
183 Code should not access this directly but instead call
184 :meth:`get_permission_prefix()`.
186 .. attribute:: url_prefix
188 Optional override for the view's URL prefix,
189 e.g. ``'/widgets'``.
191 Code should not access this directly but instead call
192 :meth:`get_url_prefix()`.
194 .. attribute:: template_prefix
196 Optional override for the view's template prefix,
197 e.g. ``'/widgets'``.
199 Code should not access this directly but instead call
200 :meth:`get_template_prefix()`.
202 .. attribute:: listable
204 Boolean indicating whether the view model supports "listing" -
205 i.e. it should have an :meth:`index()` view. Default value is
206 ``True``.
208 .. attribute:: has_grid
210 Boolean indicating whether the :meth:`index()` view should
211 include a grid. Default value is ``True``.
213 .. attribute:: grid_columns
215 List of columns for the :meth:`index()` view grid.
217 This is optional; see also :meth:`get_grid_columns()`.
219 .. attribute:: has_grid_totals
221 Boolean indicating whether the main grid supports a "Show
222 Totals" feature; this is false by default.
224 See also :meth:`fetch_grid_totals()`.
226 .. attribute:: checkable
228 Boolean indicating whether the grid should expose per-row
229 checkboxes. This is passed along to set
230 :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid.
232 .. method:: grid_row_class(obj, data, i)
234 This method is *not* defined on the ``MasterView`` base class;
235 however if a subclass defines it then it will be automatically
236 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
237 the main :meth:`index()` grid.
239 For more info see
240 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
242 .. attribute:: filterable
244 Boolean indicating whether the grid for the :meth:`index()`
245 view should allow filtering of data. Default is ``True``.
247 This is used by :meth:`make_model_grid()` to set the grid's
248 :attr:`~wuttaweb.grids.base.Grid.filterable` flag.
250 .. attribute:: filter_defaults
252 Optional dict of default filter state.
254 This is used by :meth:`make_model_grid()` to set the grid's
255 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
257 Only relevant if :attr:`filterable` is true.
259 .. attribute:: sortable
261 Boolean indicating whether the grid for the :meth:`index()`
262 view should allow sorting of data. Default is ``True``.
264 This is used by :meth:`make_model_grid()` to set the grid's
265 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
267 See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
269 .. attribute:: sort_on_backend
271 Boolean indicating whether the grid data for the
272 :meth:`index()` view should be sorted on the backend. Default
273 is ``True``.
275 This is used by :meth:`make_model_grid()` to set the grid's
276 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
278 Only relevant if :attr:`sortable` is true.
280 .. attribute:: sort_defaults
282 Optional list of default sorting info. Applicable for both
283 frontend and backend sorting.
285 This is used by :meth:`make_model_grid()` to set the grid's
286 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
288 Only relevant if :attr:`sortable` is true.
290 .. attribute:: paginated
292 Boolean indicating whether the grid data for the
293 :meth:`index()` view should be paginated. Default is ``True``.
295 This is used by :meth:`make_model_grid()` to set the grid's
296 :attr:`~wuttaweb.grids.base.Grid.paginated` flag.
298 .. attribute:: paginate_on_backend
300 Boolean indicating whether the grid data for the
301 :meth:`index()` view should be paginated on the backend.
302 Default is ``True``.
304 This is used by :meth:`make_model_grid()` to set the grid's
305 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
307 .. attribute:: creatable
309 Boolean indicating whether the view model supports "creating" -
310 i.e. it should have a :meth:`create()` view. Default value is
311 ``True``.
313 .. attribute:: viewable
315 Boolean indicating whether the view model supports "viewing" -
316 i.e. it should have a :meth:`view()` view. Default value is
317 ``True``.
319 .. attribute:: editable
321 Boolean indicating whether the view model supports "editing" -
322 i.e. it should have an :meth:`edit()` view. Default value is
323 ``True``.
325 See also :meth:`is_editable()`.
327 .. attribute:: deletable
329 Boolean indicating whether the view model supports "deleting" -
330 i.e. it should have a :meth:`delete()` view. Default value is
331 ``True``.
333 See also :meth:`is_deletable()`.
335 .. attribute:: deletable_bulk
337 Boolean indicating whether the view model supports "bulk
338 deleting" - i.e. it should have a :meth:`delete_bulk()` view.
339 Default value is ``False``.
341 See also :attr:`deletable_bulk_quick`.
343 .. attribute:: deletable_bulk_quick
345 Boolean indicating whether the view model supports "quick" bulk
346 deleting, i.e. the operation is reliably quick enough that it
347 should happen *synchronously* with no progress indicator.
349 Default is ``False`` in which case a progress indicator is
350 shown while the bulk deletion is performed.
352 Only relevant if :attr:`deletable_bulk` is true.
354 .. attribute:: form_fields
356 List of fields for the model form.
358 This is optional; see also :meth:`get_form_fields()`.
360 .. attribute:: has_autocomplete
362 Boolean indicating whether the view model supports
363 "autocomplete" - i.e. it should have an :meth:`autocomplete()`
364 view. Default is ``False``.
366 .. attribute:: downloadable
368 Boolean indicating whether the view model supports
369 "downloading" - i.e. it should have a :meth:`download()` view.
370 Default is ``False``.
372 .. attribute:: executable
374 Boolean indicating whether the view model supports "executing"
375 - i.e. it should have an :meth:`execute()` view. Default is
376 ``False``.
378 .. attribute:: configurable
380 Boolean indicating whether the master view supports
381 "configuring" - i.e. it should have a :meth:`configure()` view.
382 Default value is ``False``.
384 .. attribute:: version_grid_columns
386 List of columns for the :meth:`view_versions()` view grid.
388 This is optional; see also :meth:`get_version_grid_columns()`.
390 .. attribute:: mergeable
392 Boolean indicating whether the view model supports "merging two
393 records" - i.e. it should have a :meth:`merge()` view. Default
394 value is ``False``.
396 .. attribute:: merge_additive_fields
398 Optional list of fields for which values are "additive" in
399 nature when merging two records. Only relevant if
400 :attr:`mergeable` is true.
402 See also :meth:`merge_get_additive_fields()`.
404 .. attribute:: merge_coalesce_fields
406 Optional list of fields for which values should be "coalesced"
407 when merging two records. Only relevant if :attr:`mergeable`
408 is true.
410 See also :meth:`merge_get_coalesce_fields()`.
412 .. attribute:: merge_simple_fields
414 Optional list of "simple" fields when merging two records.
415 Only relevant if :attr:`mergeable` is true.
417 See also :meth:`merge_get_simple_fields()`.
419 **ROW FEATURES**
421 .. attribute:: has_rows
423 Whether the model has "child rows" which should also be
424 displayed when viewing model records. For instance when
425 viewing a :term:`batch` you want to see both the batch header
426 as well as its row data.
428 This the "master switch" for all row features; if this is turned
429 on then many other things kick in.
431 See also :attr:`row_model_class`.
433 .. attribute:: row_model_class
435 Reference to the :term:`data model` class for the child rows.
437 Subclass should define this if :attr:`has_rows` is true.
439 View logic should not access this directly but instead call
440 :meth:`get_row_model_class()`.
442 .. attribute:: row_model_key
444 Optional override for the view's "row model key" -
445 e.g. ``"id"`` (string for simple case) or composite key such as
446 ``("id_field", "name_field")``.
448 If :attr:`row_model_class` is set to a SQLAlchemy mapped class,
449 the row model key can be determined automatically. Otherwise
450 the view class should set it explicitly.
452 Code should not access this directly but instead call
453 :meth:`get_row_model_key()`.
455 .. attribute:: row_model_name
457 Optional override for the view's row model name,
458 e.g. ``'WuttaWidget'``.
460 Code should not access this directly but instead call
461 :meth:`get_row_model_name()`.
463 .. attribute:: row_model_title
465 Optional override for the view's "humanized" (singular) row
466 model title, e.g. ``"Wutta Widget"``.
468 Code should not access this directly but instead call
469 :meth:`get_row_model_title()`.
471 .. attribute:: row_model_title_plural
473 Optional override for the view's "humanized" (plural) row model
474 title, e.g. ``"Wutta Widgets"``.
476 Code should not access this directly but instead call
477 :meth:`get_row_model_title_plural()`.
479 .. attribute:: rows_title
481 Optional explicit display title for the rows grid.
483 Callers should typically not access this directly but instead
484 call :meth:`get_rows_title()`.
486 .. attribute:: row_grid_columns
488 List of columns for the row grid.
490 This is optional; see also :meth:`get_row_grid_columns()`.
492 .. attribute:: rows_sortable
494 Boolean indicating whether the **rows** grid for the
495 :meth:`view()` view should allow sorting of data. Default is
496 ``True``.
498 This is used by :meth:`make_row_model_grid()` to set the grid's
499 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
501 See also :attr:`rows_sort_on_backend` and
502 :attr:`rows_sort_defaults`.
504 .. attribute:: rows_sort_on_backend
506 Boolean indicating whether the **rows** grid data for the
507 :meth:`view()` view should be sorted on the backend. Default
508 is ``True``.
510 This is used by :meth:`make_row_model_grid()` to set the grid's
511 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
513 Only relevant if :attr:`rows_sortable` is true.
515 .. attribute:: rows_sort_defaults
517 Optional list of default sorting info for the **rows** grid.
518 Applicable for both frontend and backend sorting.
520 This is used by :meth:`make_row_model_grid()` to set the grid's
521 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
523 Only relevant if :attr:`rows_sortable` is true.
525 .. attribute:: rows_viewable
527 Boolean indicating whether the row model supports "viewing" -
528 i.e. there should be a :meth:`view_row()` view, and the row
529 grid should include a "View" action link. Default is
530 ``False``.
532 See also :meth:`get_row_action_url_view()`.
534 .. attribute:: rows_editable
536 Boolean indicating whether the row model supports "editing" -
537 i.e. there should be an :meth:`edit_row()` view, and the row
538 grid should include an "Edit" action link. Default is
539 ``False``.
541 See also :meth:`is_row_editable()`.
543 .. attribute:: rows_deletable
545 Boolean indicating whether the row model supports "deleting" -
546 i.e. there should be a :meth:`delete_row()` view, and the row
547 grid should include a "Delete" action link. Default is
548 ``False``.
550 See also :meth:`is_row_deletable()`.
552 .. attribute:: row_form_fields
554 List of fields for the row model form.
556 This is optional; see also :meth:`get_row_form_fields()`.
558 .. attribute:: rows_creatable
560 Boolean indicating whether the row model supports "creating" -
561 i.e. a route should be defined for :meth:`create_row()`.
562 Default value is ``False``.
563 """
565 ##############################
566 # attributes
567 ##############################
569 model_class = None
571 # features
572 listable = True
573 has_grid = True
574 has_grid_totals = False
575 checkable = False
576 filterable = True
577 filter_defaults = None
578 sortable = True
579 sort_on_backend = True
580 sort_defaults = None
581 paginated = True
582 paginate_on_backend = True
583 creatable = True
584 viewable = True
585 editable = True
586 deletable = True
587 deletable_bulk = False
588 deletable_bulk_quick = False
589 has_autocomplete = False
590 downloadable = False
591 executable = False
592 execute_progress_template = None
593 configurable = False
595 # merging
596 mergeable = False
597 merge_additive_fields = None
598 merge_coalesce_fields = None
599 merge_simple_fields = None
601 # row features
602 has_rows = False
603 row_model_class = None
604 rows_filterable = True
605 rows_filter_defaults = None
606 rows_sortable = True
607 rows_sort_on_backend = True
608 rows_sort_defaults = None
609 rows_paginated = True
610 rows_paginate_on_backend = True
611 rows_creatable = False
612 rows_viewable = False
613 rows_editable = False
614 rows_deletable = False
616 # current action
617 listing = False
618 creating = False
619 viewing = False
620 editing = False
621 deleting = False
622 executing = False
623 configuring = False
625 # default DB session
626 Session = Session
628 ##############################
629 # index methods
630 ##############################
632 def index(self):
633 """
634 View to "list" (filter/browse) the model data.
636 This is the "default" view for the model and is what user sees
637 when visiting the "root" path under the :attr:`url_prefix`,
638 e.g. ``/widgets/``.
640 By default, this view is included only if :attr:`listable` is
641 true.
643 The default view logic will show a "grid" (table) with the
644 model data (unless :attr:`has_grid` is false).
646 See also related methods, which are called by this one:
648 * :meth:`make_model_grid()`
649 """
650 self.listing = True
652 context = {
653 "index_url": None, # nb. avoid title link since this *is* the index
654 }
656 if self.has_grid:
657 grid = self.make_model_grid()
659 # handle "full" vs. "partial" differently
660 if self.request.GET.get("partial"):
662 # so-called 'partial' requests get just data, no html
663 context = grid.get_vue_context()
664 if grid.paginated and grid.paginate_on_backend:
665 context["pager_stats"] = grid.get_vue_pager_stats()
666 return self.json_response(context)
668 # full, not partial
670 # nb. when user asks to reset view, it is via the query
671 # string. if so we then redirect to discard that.
672 if self.request.GET.get("reset-view"):
674 # nb. we want to preserve url hash if applicable
675 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
676 return self.redirect(self.request.current_route_url(**kw))
678 context["grid"] = grid
680 return self.render_to_response("index", context)
682 def fetch_grid_totals(self):
683 """
684 Should return the "totals info" for the main grid, if
685 applicable. Only relevant when :attr:`has_grid_totals` is
686 true.
688 This method is called "on demand" from the client side; totals
689 are not calculated / shown by default when a grid is first
690 displayed on the page.
692 Subclass should override this method to calculate and return
693 the customized info. Default logic within the template is
694 expecting a ``totals_html`` key within the dict; this will be
695 rendered as-is on the page. For instance::
697 def fetch_grid_totals(self):
699 from webhelpers2.html import HTML
701 # get current data set from grid
702 # nb. this will be filtered and sorted but *not*
703 # paginated; we want to include all results.
704 grid = self.make_model_grid(paginated=False)
705 rows = grid.get_visible_data()
707 # calculate total
708 foo_total = sum([row.foo_amount for row in rows])
710 # render as <span> tag
711 html = HTML.tag("span", c=f"Foo Total: {foo_total:0.2f}")
712 return {"totals_html": html}
714 :returns: Dict of totals info.
715 """
716 return {"totals_html": "TODO: totals go here"}
718 ##############################
719 # create methods
720 ##############################
722 def create(self):
723 """
724 View to "create" a new model record.
726 This usually corresponds to URL like ``/widgets/new``
728 By default, this route is included only if :attr:`creatable`
729 is true.
731 The default logic calls :meth:`make_create_form()` and shows
732 that to the user. When they submit valid data, it calls
733 :meth:`save_create_form()` and then
734 :meth:`redirect_after_create()`.
735 """
736 self.creating = True
737 form = self.make_create_form()
739 if form.validate():
740 session = self.Session()
741 try:
742 result = self.save_create_form(form)
743 # nb. must always flush to ensure primary key is set
744 session.flush()
745 except Exception as err: # pylint: disable=broad-exception-caught
746 log.warning("failed to save 'create' form", exc_info=True)
747 self.request.session.flash(f"Create failed: {err}", "error")
748 else:
749 return self.redirect_after_create(result)
751 context = {"form": form}
752 return self.render_to_response("create", context)
754 def make_create_form(self):
755 """
756 Make the "create" model form. This is called by
757 :meth:`create()`.
759 Default logic calls :meth:`make_model_form()`.
761 :returns: :class:`~wuttaweb.forms.base.Form` instance
762 """
763 return self.make_model_form(cancel_url_fallback=self.get_index_url())
765 def save_create_form(self, form):
766 """
767 Save the "create" form. This is called by :meth:`create()`.
769 Default logic calls :meth:`objectify()` and then
770 :meth:`persist()`. Subclass is expected to override for
771 non-standard use cases.
773 As for return value, by default it will be whatever came back
774 from the ``objectify()`` call. In practice a subclass can
775 return whatever it likes. The value is only used as input to
776 :meth:`redirect_after_create()`.
778 :returns: Usually the model instance, but can be "anything"
779 """
780 if hasattr(self, "create_save_form"): # pragma: no cover
781 warnings.warn(
782 "MasterView.create_save_form() method name is deprecated; "
783 f"please refactor to save_create_form() instead for {self.__class__.__name__}",
784 DeprecationWarning,
785 )
786 return self.create_save_form(form)
788 obj = self.objectify(form)
789 self.persist(obj)
790 return obj
792 def redirect_after_create(self, result):
793 """
794 Must return a redirect, following successful save of the
795 "create" form. This is called by :meth:`create()`.
797 By default this redirects to the "view" page for the new
798 record.
800 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
801 """
802 return self.redirect(self.get_action_url("view", result))
804 ##############################
805 # view methods
806 ##############################
808 def view(self):
809 """
810 View to "view" a model record.
812 This usually corresponds to URL like ``/widgets/XXX``
814 By default, this route is included only if :attr:`viewable` is
815 true.
817 The default logic here is as follows:
819 First, if :attr:`has_rows` is true then
820 :meth:`make_row_model_grid()` is called.
822 If ``has_rows`` is true *and* the request has certain special
823 params relating to the grid, control may exit early. Mainly
824 this happens when a "partial" page is requested, which means
825 we just return grid data and nothing else. (Used for backend
826 sorting and pagination etc.)
828 Otherwise :meth:`make_view_form()` is called, and the template
829 is rendered.
830 """
831 self.viewing = True
832 obj = self.get_instance()
833 context = {"instance": obj}
835 if self.has_rows:
837 # always make the grid first. note that it already knows
838 # to "reset" its params when that is requested.
839 grid = self.make_row_model_grid(obj)
841 # but if user did request a "reset" then we want to
842 # redirect so the query string gets cleared out
843 if self.request.GET.get("reset-view"):
845 # nb. we want to preserve url hash if applicable
846 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
847 return self.redirect(self.request.current_route_url(**kw))
849 # so-called 'partial' requests get just the grid data
850 if self.request.params.get("partial"):
851 context = grid.get_vue_context()
852 if grid.paginated and grid.paginate_on_backend:
853 context["pager_stats"] = grid.get_vue_pager_stats()
854 return self.json_response(context)
856 context["rows_grid"] = grid
858 context["form"] = self.make_view_form(obj)
859 context["xref_buttons"] = self.get_xref_buttons(obj)
860 return self.render_to_response("view", context)
862 def make_view_form(self, obj, readonly=True):
863 """
864 Make the "view" model form. This is called by
865 :meth:`view()`.
867 Default logic calls :meth:`make_model_form()`.
869 :returns: :class:`~wuttaweb.forms.base.Form` instance
870 """
871 return self.make_model_form(obj, readonly=readonly)
873 ##############################
874 # edit methods
875 ##############################
877 def edit(self):
878 """
879 View to "edit" a model record.
881 This usually corresponds to URL like ``/widgets/XXX/edit``
883 By default, this route is included only if :attr:`editable` is
884 true.
886 The default logic calls :meth:`make_edit_form()` and shows
887 that to the user. When they submit valid data, it calls
888 :meth:`save_edit_form()` and then
889 :meth:`redirect_after_edit()`.
890 """
891 self.editing = True
892 instance = self.get_instance()
893 form = self.make_edit_form(instance)
895 if form.validate():
896 try:
897 result = self.save_edit_form(form)
898 except Exception as err: # pylint: disable=broad-exception-caught
899 log.warning("failed to save 'edit' form", exc_info=True)
900 self.request.session.flash(f"Edit failed: {err}", "error")
901 else:
902 return self.redirect_after_edit(result)
904 context = {
905 "instance": instance,
906 "form": form,
907 }
908 return self.render_to_response("edit", context)
910 def make_edit_form(self, obj):
911 """
912 Make the "edit" model form. This is called by
913 :meth:`edit()`.
915 Default logic calls :meth:`make_model_form()`.
917 :returns: :class:`~wuttaweb.forms.base.Form` instance
918 """
919 return self.make_model_form(
920 obj, cancel_url_fallback=self.get_action_url("view", obj)
921 )
923 def save_edit_form(self, form):
924 """
925 Save the "edit" form. This is called by :meth:`edit()`.
927 Default logic calls :meth:`objectify()` and then
928 :meth:`persist()`. Subclass is expected to override for
929 non-standard use cases.
931 As for return value, by default it will be whatever came back
932 from the ``objectify()`` call. In practice a subclass can
933 return whatever it likes. The value is only used as input to
934 :meth:`redirect_after_edit()`.
936 :returns: Usually the model instance, but can be "anything"
937 """
938 if hasattr(self, "edit_save_form"): # pragma: no cover
939 warnings.warn(
940 "MasterView.edit_save_form() method name is deprecated; "
941 f"please refactor to save_edit_form() instead for {self.__class__.__name__}",
942 DeprecationWarning,
943 )
944 return self.edit_save_form(form)
946 obj = self.objectify(form)
947 self.persist(obj)
948 return obj
950 def redirect_after_edit(self, result):
951 """
952 Must return a redirect, following successful save of the
953 "edit" form. This is called by :meth:`edit()`.
955 By default this redirects to the "view" page for the record.
957 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
958 """
959 return self.redirect(self.get_action_url("view", result))
961 ##############################
962 # delete methods
963 ##############################
965 def delete(self):
966 """
967 View to "delete" a model record.
969 This usually corresponds to URL like ``/widgets/XXX/delete``
971 By default, this route is included only if :attr:`deletable`
972 is true.
974 The default logic calls :meth:`make_delete_form()` and shows
975 that to the user. When they submit, it calls
976 :meth:`save_delete_form()` and then
977 :meth:`redirect_after_delete()`.
978 """
979 self.deleting = True
980 instance = self.get_instance()
982 if not self.is_deletable(instance):
983 return self.redirect(self.get_action_url("view", instance))
985 form = self.make_delete_form(instance)
987 # nb. validate() often returns empty dict here
988 if form.validate() is not False:
990 try:
991 result = self.save_delete_form( # pylint: disable=assignment-from-none
992 form
993 )
994 except Exception as err: # pylint: disable=broad-exception-caught
995 log.warning("failed to save 'delete' form", exc_info=True)
996 self.request.session.flash(f"Delete failed: {err}", "error")
997 else:
998 return self.redirect_after_delete(result)
1000 context = {
1001 "instance": instance,
1002 "form": form,
1003 }
1004 return self.render_to_response("delete", context)
1006 def make_delete_form(self, obj):
1007 """
1008 Make the "delete" model form. This is called by
1009 :meth:`delete()`.
1011 Default logic calls :meth:`make_model_form()` but with a
1012 twist:
1014 The form proper is *not* readonly; this ensures the form has a
1015 submit button etc. But then all fields in the form are
1016 explicitly marked readonly.
1018 :returns: :class:`~wuttaweb.forms.base.Form` instance
1019 """
1020 # nb. this form proper is not readonly..
1021 form = self.make_model_form(
1022 obj,
1023 cancel_url_fallback=self.get_action_url("view", obj),
1024 button_label_submit="DELETE Forever",
1025 button_icon_submit="trash",
1026 button_type_submit="is-danger",
1027 )
1029 # ..but *all* fields are readonly
1030 for field in form.fields:
1031 form.set_readonly(field)
1033 return form
1035 def save_delete_form(self, form):
1036 """
1037 Save the "delete" form. This is called by :meth:`delete()`.
1039 Default logic calls :meth:`delete_instance()`. Normally
1040 subclass would override that for non-standard use cases, but
1041 it could also/instead override this method.
1043 As for return value, by default this returns ``None``. In
1044 practice a subclass can return whatever it likes. The value
1045 is only used as input to :meth:`redirect_after_delete()`.
1047 :returns: Usually ``None``, but can be "anything"
1048 """
1049 if hasattr(self, "delete_save_form"): # pragma: no cover
1050 warnings.warn(
1051 "MasterView.delete_save_form() method name is deprecated; "
1052 f"please refactor to save_delete_form() instead for {self.__class__.__name__}",
1053 DeprecationWarning,
1054 )
1055 self.delete_save_form(form)
1056 return
1058 obj = form.model_instance
1059 self.delete_instance(obj)
1061 def redirect_after_delete(self, result): # pylint: disable=unused-argument
1062 """
1063 Must return a redirect, following successful save of the
1064 "delete" form. This is called by :meth:`delete()`.
1066 By default this redirects back to the :meth:`index()` page.
1068 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
1069 """
1070 return self.redirect(self.get_index_url())
1072 def delete_instance(self, obj):
1073 """
1074 Delete the given model instance.
1076 As of yet there is no default logic for this method; it will
1077 raise ``NotImplementedError``. Subclass should override if
1078 needed.
1080 This method is called by :meth:`save_delete_form()`.
1081 """
1082 session = self.app.get_session(obj)
1083 session.delete(obj)
1085 def delete_bulk(self):
1086 """
1087 View to delete all records in the current :meth:`index()` grid
1088 data set, i.e. those matching current query.
1090 This usually corresponds to a URL like
1091 ``/widgets/delete-bulk``.
1093 By default, this view is included only if
1094 :attr:`deletable_bulk` is true.
1096 This view requires POST method. When it is finished deleting,
1097 user is redirected back to :meth:`index()` view.
1099 Subclass normally should not override this method, but rather
1100 one of the related methods which are called (in)directly by
1101 this one:
1103 * :meth:`delete_bulk_action()`
1104 """
1106 # get current data set from grid
1107 # nb. this must *not* be paginated, we need it all
1108 grid = self.make_model_grid(paginated=False)
1109 data = grid.get_visible_data()
1111 if self.deletable_bulk_quick:
1113 # delete it all and go back to listing
1114 self.delete_bulk_action(data)
1115 return self.redirect(self.get_index_url())
1117 # start thread for delete; show progress page
1118 route_prefix = self.get_route_prefix()
1119 key = f"{route_prefix}.delete_bulk"
1120 progress = self.make_progress(key, success_url=self.get_index_url())
1121 thread = threading.Thread(
1122 target=self.delete_bulk_thread,
1123 args=(data,),
1124 kwargs={"progress": progress},
1125 )
1126 thread.start()
1127 return self.render_progress(progress)
1129 def delete_bulk_thread( # pylint: disable=empty-docstring
1130 self, query, progress=None
1131 ):
1132 """ """
1133 session = self.app.make_session()
1134 records = query.with_session(session).all()
1136 def onerror():
1137 log.warning(
1138 "failed to delete %s results for %s",
1139 len(records),
1140 self.get_model_title_plural(),
1141 exc_info=True,
1142 )
1144 self.do_thread_body(
1145 self.delete_bulk_action,
1146 (records,),
1147 {"progress": progress},
1148 onerror,
1149 session=session,
1150 progress=progress,
1151 )
1153 def delete_bulk_action(self, data, progress=None):
1154 """
1155 This method performs the actual bulk deletion, for the given
1156 data set. This is called via :meth:`delete_bulk()`.
1158 Default logic will call :meth:`is_deletable()` for every data
1159 record, and if that returns true then it calls
1160 :meth:`delete_instance()`. A progress indicator will be
1161 updated if one is provided.
1163 Subclass should override if needed.
1164 """
1165 model_title_plural = self.get_model_title_plural()
1167 def delete(obj, i): # pylint: disable=unused-argument
1168 if self.is_deletable(obj):
1169 self.delete_instance(obj)
1171 self.app.progress_loop(
1172 delete, data, progress, message=f"Deleting {model_title_plural}"
1173 )
1175 def delete_bulk_make_button(self): # pylint: disable=empty-docstring
1176 """ """
1177 route_prefix = self.get_route_prefix()
1179 label = HTML.literal(
1180 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}'
1181 )
1182 button = self.make_button(
1183 label,
1184 variant="is-danger",
1185 icon_left="trash",
1186 **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"},
1187 )
1189 form = HTML.tag(
1190 "form",
1191 method="post",
1192 action=self.request.route_url(f"{route_prefix}.delete_bulk"),
1193 ref="deleteResultsForm",
1194 class_="control",
1195 c=[
1196 render_csrf_token(self.request),
1197 button,
1198 ],
1199 )
1200 return form
1202 ##############################
1203 # version history methods
1204 ##############################
1206 @classmethod
1207 def is_versioned(cls):
1208 """
1209 Returns boolean indicating whether the model class is
1210 configured for SQLAlchemy-Continuum versioning.
1212 The default logic will directly inspect the model class, as
1213 returned by :meth:`get_model_class()`. Or you can override by
1214 setting the ``model_is_versioned`` attribute::
1216 class WidgetView(MasterView):
1217 model_class = Widget
1218 model_is_versioned = False
1220 See also :meth:`should_expose_versions()`.
1222 :returns: ``True`` if the model class is versioned; else
1223 ``False``.
1224 """
1225 if hasattr(cls, "model_is_versioned"):
1226 return cls.model_is_versioned
1228 model_class = cls.get_model_class()
1229 if hasattr(model_class, "__versioned__"):
1230 return True
1232 return False
1234 @classmethod
1235 def get_model_version_class(cls):
1236 """
1237 Returns the version class for the master model class.
1239 Should only be relevant if :meth:`is_versioned()` is true.
1240 """
1241 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1243 return continuum.version_class(cls.get_model_class())
1245 def should_expose_versions(self):
1246 """
1247 Returns boolean indicating whether versioning history should
1248 be exposed for the current user. This will return ``True``
1249 unless any of the following are ``False``:
1251 * :meth:`is_versioned()`
1252 * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()`
1253 * ``self.has_perm("versions")`` - cf. :meth:`has_perm()`
1255 :returns: ``True`` if versioning should be exposed for current
1256 user; else ``False``.
1257 """
1258 if not self.is_versioned():
1259 return False
1261 if not self.app.continuum_is_enabled():
1262 return False
1264 if not self.has_perm("versions"):
1265 return False
1267 return True
1269 def view_versions(self):
1270 """
1271 View to list version history for an object. See also
1272 :meth:`view_version()`.
1274 This usually corresponds to a URL like
1275 ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID
1276 for the record.
1278 By default, this view is included only if
1279 :meth:`is_versioned()` is true.
1281 The default view logic will show a "grid" (table) with the
1282 record's version history.
1284 See also:
1286 * :meth:`make_version_grid()`
1287 """
1288 instance = self.get_instance()
1289 instance_title = self.get_instance_title(instance)
1290 grid = self.make_version_grid(instance)
1292 # return grid data only, if partial page was requested
1293 if self.request.GET.get("partial"):
1294 context = grid.get_vue_context()
1295 if grid.paginated and grid.paginate_on_backend:
1296 context["pager_stats"] = grid.get_vue_pager_stats()
1297 return self.json_response(context)
1299 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1301 instance_link = tags.link_to(
1302 instance_title, self.get_action_url("view", instance)
1303 )
1305 index_title_rendered = HTML.literal("<span> »</span>").join(
1306 [index_link, instance_link]
1307 )
1309 return self.render_to_response(
1310 "view_versions",
1311 {
1312 "index_title_rendered": index_title_rendered,
1313 "instance": instance,
1314 "instance_title": instance_title,
1315 "instance_url": self.get_action_url("view", instance),
1316 "grid": grid,
1317 },
1318 )
1320 def make_version_grid(self, instance=None, **kwargs):
1321 """
1322 Create and return a grid for use with the
1323 :meth:`view_versions()` view.
1325 See also related methods, which are called by this one:
1327 * :meth:`get_version_grid_key()`
1328 * :meth:`get_version_grid_columns()`
1329 * :meth:`get_version_grid_data()`
1330 * :meth:`configure_version_grid()`
1332 :returns: :class:`~wuttaweb.grids.base.Grid` instance
1333 """
1334 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1336 route_prefix = self.get_route_prefix()
1337 # instance = kwargs.pop("instance", None)
1338 if not instance:
1339 instance = self.get_instance()
1341 if "key" not in kwargs:
1342 kwargs["key"] = self.get_version_grid_key()
1344 if "model_class" not in kwargs:
1345 kwargs["model_class"] = continuum.transaction_class(self.get_model_class())
1347 if "columns" not in kwargs:
1348 kwargs["columns"] = self.get_version_grid_columns()
1350 if "data" not in kwargs:
1351 kwargs["data"] = self.get_version_grid_data(instance)
1353 if "actions" not in kwargs:
1354 route = f"{route_prefix}.version"
1356 def url(txn, i): # pylint: disable=unused-argument
1357 return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
1359 kwargs["actions"] = [
1360 self.make_grid_action("view", icon="eye", url=url),
1361 ]
1363 kwargs.setdefault("paginated", True)
1365 grid = self.make_grid(**kwargs)
1366 self.configure_version_grid(grid)
1367 grid.load_settings()
1368 return grid
1370 @classmethod
1371 def get_version_grid_key(cls):
1372 """
1373 Returns the unique key to be used for the version grid, for caching
1374 sort/filter options etc.
1376 This is normally called automatically from :meth:`make_version_grid()`.
1378 :returns: Grid key as string
1379 """
1380 if hasattr(cls, "version_grid_key"):
1381 return cls.version_grid_key
1382 return f"{cls.get_route_prefix()}.history"
1384 def get_version_grid_columns(self):
1385 """
1386 Returns the default list of version grid column names, for the
1387 :meth:`view_versions()` view.
1389 This is normally called automatically by
1390 :meth:`make_version_grid()`.
1392 Subclass may define :attr:`version_grid_columns` for simple
1393 cases, or can override this method if needed.
1395 :returns: List of string column names
1396 """
1397 if hasattr(self, "version_grid_columns"):
1398 return self.version_grid_columns
1400 return [
1401 "id",
1402 "issued_at",
1403 "user",
1404 "remote_addr",
1405 "comment",
1406 ]
1408 def get_version_grid_data(self, instance):
1409 """
1410 Returns the grid data query for the :meth:`view_versions()`
1411 view.
1413 This is normally called automatically by
1414 :meth:`make_version_grid()`.
1416 Default query will locate SQLAlchemy-Continuum ``transaction``
1417 records which are associated with versions of the given model
1418 instance. See also:
1420 * :meth:`get_version_joins()`
1421 * :meth:`normalize_version_joins()`
1422 * :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
1424 :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
1425 """
1426 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1427 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1428 model_transaction_query,
1429 )
1431 model_class = self.get_model_class()
1432 txncls = continuum.transaction_class(model_class)
1433 query = model_transaction_query(instance, joins=self.normalize_version_joins())
1434 return query.order_by(txncls.issued_at.desc())
1436 def get_version_joins(self):
1437 """
1438 Override this method to declare additional version tables
1439 which should be joined when showing the overall revision
1440 history for a given model instance.
1442 Note that whatever this method returns, will be ran through
1443 :meth:`normalize_version_joins()` before being passed along to
1444 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1446 :returns: List of version joins info as described below.
1448 In the simple scenario where an "extension" table is involved,
1449 e.g. a ``UserExtension`` table::
1451 def get_version_joins(self):
1452 model = self.app.model
1453 return super().get_version_joins() + [
1454 model.UserExtension,
1455 ]
1457 In the case where a secondary table is "related" to the main
1458 model table, but not a standard extension (using the
1459 ``User.person`` relationship as example)::
1461 def get_version_joins(self):
1462 model = self.app.model
1463 return super().get_version_joins() + [
1464 (model.Person, "uuid", "person_uuid"),
1465 ]
1467 See also :meth:`get_version_grid_data()`.
1468 """
1469 return []
1471 def normalize_version_joins(self):
1472 """
1473 This method calls :meth:`get_version_joins()` and normalizes
1474 the result, which will then get passed along to
1475 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1477 Subclass should (generally) not override this, but instead
1478 override :meth:`get_version_joins()`.
1480 Each element in the return value (list) will be a 3-tuple
1481 conforming to what is needed for the query function.
1483 See also :meth:`get_version_grid_data()`.
1485 :returns: List of version joins info.
1486 """
1487 joins = []
1488 for join in self.get_version_joins():
1489 if not isinstance(join, tuple):
1490 join = (join, "uuid", "uuid")
1491 joins.append(join)
1492 return joins
1494 def configure_version_grid(self, g):
1495 """
1496 Configure the grid for the :meth:`view_versions()` view.
1498 This is called automatically by :meth:`make_version_grid()`.
1500 Default logic applies basic customization to the column labels etc.
1501 """
1502 # id
1503 g.set_label("id", "TXN ID")
1504 # g.set_link("id")
1506 # issued_at
1507 g.set_label("issued_at", "Changed")
1508 g.set_link("issued_at")
1509 g.set_sort_defaults("issued_at", "desc")
1511 # user
1512 g.set_label("user", "Changed by")
1513 g.set_link("user")
1515 # remote_addr
1516 g.set_label("remote_addr", "IP Address")
1518 # comment
1519 g.set_renderer("comment", self.render_version_comment)
1521 def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument
1522 self, txn, key, value
1523 ):
1524 return txn.meta.get("comment", "")
1526 def view_version(self): # pylint: disable=too-many-locals
1527 """
1528 View to show diff details for a particular object version.
1529 See also :meth:`view_versions()`.
1531 This usually corresponds to a URL like
1532 ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the
1533 key/ID for the record and YYY represents a
1534 SQLAlchemy-Continuum ``transaction.id``.
1536 By default, this view is included only if
1537 :meth:`is_versioned()` is true.
1539 The default view logic will display a "diff" table showing how
1540 the record's values were changed within a transaction.
1542 See also:
1544 * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`
1545 * :meth:`get_relevant_versions()`
1546 * :class:`~wuttaweb.diffs.VersionDiff`
1547 """
1548 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1549 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1550 model_transaction_query,
1551 )
1553 instance = self.get_instance()
1554 model_class = self.get_model_class()
1555 route_prefix = self.get_route_prefix()
1556 txncls = continuum.transaction_class(model_class)
1557 transactions = model_transaction_query(
1558 instance, joins=self.normalize_version_joins()
1559 )
1561 txnid = int(self.request.matchdict["txnid"])
1562 txn = transactions.filter(txncls.id == txnid).first()
1563 if not txn:
1564 raise self.notfound()
1566 prev_url = None
1567 older = (
1568 transactions.filter(txncls.issued_at <= txn.issued_at)
1569 .filter(txncls.id != txnid)
1570 .order_by(txncls.issued_at.desc())
1571 .first()
1572 )
1573 if older:
1574 prev_url = self.request.route_url(
1575 f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id
1576 )
1578 next_url = None
1579 newer = (
1580 transactions.filter(txncls.issued_at >= txn.issued_at)
1581 .filter(txncls.id != txnid)
1582 .order_by(txncls.issued_at)
1583 .first()
1584 )
1585 if newer:
1586 next_url = self.request.route_url(
1587 f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id
1588 )
1590 version_diffs = [
1591 VersionDiff(self.config, version)
1592 for version in self.get_relevant_versions(txn, instance)
1593 ]
1595 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1597 instance_title = self.get_instance_title(instance)
1598 instance_link = tags.link_to(
1599 instance_title, self.get_action_url("view", instance)
1600 )
1602 history_link = tags.link_to(
1603 "History",
1604 self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid),
1605 )
1607 index_title_rendered = HTML.literal("<span> »</span>").join(
1608 [index_link, instance_link, history_link]
1609 )
1611 return self.render_to_response(
1612 "view_version",
1613 {
1614 "index_title_rendered": index_title_rendered,
1615 "instance": instance,
1616 "instance_title": instance_title,
1617 "instance_url": self.get_action_url("versions", instance),
1618 "transaction": txn,
1619 "changed": self.app.render_datetime(txn.issued_at, html=True),
1620 "version_diffs": version_diffs,
1621 "show_prev_next": True,
1622 "prev_url": prev_url,
1623 "next_url": next_url,
1624 },
1625 )
1627 def get_relevant_versions(self, transaction, instance):
1628 """
1629 Should return all version records pertaining to the given
1630 model instance and transaction.
1632 This is normally called from :meth:`view_version()`.
1634 :param transaction: SQLAlchemy-Continuum ``transaction``
1635 record/instance.
1637 :param instance: Instance of the model class.
1639 :returns: List of version records.
1640 """
1641 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1643 session = self.Session()
1644 vercls = self.get_model_version_class()
1645 versions = []
1647 # first get all versions for the model instance proper
1648 versions.extend(
1649 session.query(vercls)
1650 .filter(vercls.transaction == transaction)
1651 .filter(vercls.uuid == instance.uuid)
1652 .all()
1653 )
1655 # then get all related versions, per declared joins
1656 for child_class, foreign_attr, primary_attr in self.normalize_version_joins():
1657 child_vercls = continuum.version_class(child_class)
1658 versions.extend(
1659 session.query(child_vercls)
1660 .filter(child_vercls.transaction == transaction)
1661 .filter(
1662 getattr(child_vercls, foreign_attr)
1663 == getattr(instance, primary_attr)
1664 )
1665 )
1667 return versions
1669 ##############################
1670 # autocomplete methods
1671 ##############################
1673 def autocomplete(self):
1674 """
1675 View which accepts a single ``term`` param, and returns a JSON
1676 list of autocomplete results to match.
1678 By default, this view is included only if
1679 :attr:`has_autocomplete` is true. It usually maps to a URL
1680 like ``/widgets/autocomplete``.
1682 Subclass generally does not need to override this method, but
1683 rather should override the others which this calls:
1685 * :meth:`autocomplete_data()`
1686 * :meth:`autocomplete_normalize()`
1687 """
1688 term = self.request.GET.get("term", "")
1689 if not term:
1690 return []
1692 data = self.autocomplete_data(term) # pylint: disable=assignment-from-none
1693 if not data:
1694 return []
1696 max_results = 100 # TODO
1698 results = []
1699 for obj in data[:max_results]:
1700 normal = self.autocomplete_normalize(obj)
1701 if normal:
1702 results.append(normal)
1704 return results
1706 def autocomplete_data(self, term): # pylint: disable=unused-argument
1707 """
1708 Should return the data/query for the "matching" model records,
1709 based on autocomplete search term. This is called by
1710 :meth:`autocomplete()`.
1712 Subclass must override this; default logic returns no data.
1714 :param term: String search term as-is from user, e.g. "foo bar".
1716 :returns: List of data records, or SQLAlchemy query.
1717 """
1718 return None
1720 def autocomplete_normalize(self, obj):
1721 """
1722 Should return a "normalized" version of the given model
1723 record, suitable for autocomplete JSON results. This is
1724 called by :meth:`autocomplete()`.
1726 Subclass may need to override this; default logic is
1727 simplistic but will work for basic models. It returns the
1728 "autocomplete results" dict for the object::
1730 {
1731 'value': obj.uuid,
1732 'label': str(obj),
1733 }
1735 The 2 keys shown are required; any other keys will be ignored
1736 by the view logic but may be useful on the frontend widget.
1738 :param obj: Model record/instance.
1740 :returns: Dict of "autocomplete results" format, as shown
1741 above.
1742 """
1743 return {
1744 "value": obj.uuid,
1745 "label": str(obj),
1746 }
1748 ##############################
1749 # download methods
1750 ##############################
1752 def download(self):
1753 """
1754 View to download a file associated with a model record.
1756 This usually corresponds to a URL like
1757 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
1758 for the record.
1760 By default, this view is included only if :attr:`downloadable`
1761 is true.
1763 This method will (try to) locate the file on disk, and return
1764 it as a file download response to the client.
1766 The GET request for this view may contain a ``filename`` query
1767 string parameter, which can be used to locate one of various
1768 files associated with the model record. This filename is
1769 passed to :meth:`download_path()` for locating the file.
1771 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
1773 Subclass normally should not override this method, but rather
1774 one of the related methods which are called (in)directly by
1775 this one:
1777 * :meth:`download_path()`
1778 """
1779 obj = self.get_instance()
1780 filename = self.request.GET.get("filename", None)
1782 path = self.download_path(obj, filename) # pylint: disable=assignment-from-none
1783 if not path or not os.path.exists(path):
1784 return self.notfound()
1786 return self.file_response(path)
1788 def download_path(self, obj, filename): # pylint: disable=unused-argument
1789 """
1790 Should return absolute path on disk, for the given object and
1791 filename. Result will be used to return a file response to
1792 client. This is called by :meth:`download()`.
1794 Default logic always returns ``None``; subclass must override.
1796 :param obj: Refefence to the model instance.
1798 :param filename: Name of file for which to retrieve the path.
1800 :returns: Path to file, or ``None`` if not found.
1802 Note that ``filename`` may be ``None`` in which case the "default"
1803 file path should be returned, if applicable.
1805 If this method returns ``None`` (as it does by default) then
1806 the :meth:`download()` view will return a 404 not found
1807 response.
1808 """
1809 return None
1811 ##############################
1812 # execute methods
1813 ##############################
1815 def execute(self):
1816 """
1817 View to "execute" a model record. Requires a POST request.
1819 This usually corresponds to a URL like
1820 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
1821 for the record.
1823 By default, this view is included only if :attr:`executable` is
1824 true.
1826 Probably this is a "rare" view to implement for a model. But
1827 there are two notable use cases so far, namely:
1829 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
1830 * batches (not yet implemented;
1831 cf. :doc:`rattail-manual:data/batch/index` in Rattail
1832 Manual)
1834 The general idea is to take some "irrevocable" action
1835 associated with the model record. In the case of upgrades, it
1836 is to run the upgrade script. For batches it is to "push
1837 live" the data held within the batch.
1839 Subclass normally should not override this method, but rather
1840 one of the related methods which are called (in)directly by
1841 this one:
1843 * :meth:`execute_instance()`
1844 """
1845 route_prefix = self.get_route_prefix()
1846 model_title = self.get_model_title()
1847 obj = self.get_instance()
1849 # make the progress tracker
1850 progress = self.make_progress(
1851 f"{route_prefix}.execute",
1852 success_msg=f"{model_title} was executed.",
1853 success_url=self.get_action_url("view", obj),
1854 )
1856 # start thread for execute; show progress page
1857 key = self.request.matchdict
1858 thread = threading.Thread(
1859 target=self.execute_thread,
1860 args=(key, self.request.user.uuid),
1861 kwargs={"progress": progress},
1862 )
1863 thread.start()
1864 return self.render_progress(
1865 progress,
1866 context={
1867 "instance": obj,
1868 },
1869 template=self.execute_progress_template,
1870 )
1872 def execute_instance(
1873 self, obj, user, progress=None
1874 ): # pylint: disable=unused-argument
1875 """
1876 This method must perform the actual (e.g. custom) "execution"
1877 logic for a model instance. It is called from within a
1878 secondary thread which is launched from :meth:`execute()`.
1880 If execution succeeds, this method can return a string, which
1881 will be shown to the user as-is via flash message. (It can
1882 also return ``None`` in which case a generic success message
1883 is shown.)
1885 If execution fails, this method should raise an error which
1886 will be caught and handled elsewhere.
1888 :param obj: Reference to the model instance.
1890 :param user: Reference to the
1891 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1892 is doing the execute.
1894 :param progress: Optional progress indicator factory.
1896 :returns: Success message, or ``None``
1897 """
1898 return None
1900 def execute_thread( # pylint: disable=empty-docstring
1901 self, key, user_uuid, progress=None
1902 ):
1903 """ """
1904 model = self.app.model
1905 model_title = self.get_model_title()
1907 # nb. use new session, separate from web transaction
1908 session = self.app.make_session()
1910 # fetch model instance and user for this session
1911 obj = self.get_instance(session=session, matchdict=key)
1912 user = session.get(model.User, user_uuid)
1914 try:
1915 success_msg = self.execute_instance( # pylint: disable=assignment-from-none
1916 obj, user, progress=progress
1917 )
1919 except Exception as error: # pylint: disable=broad-exception-caught
1920 session.rollback()
1921 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1922 if progress:
1923 progress.handle_error(error)
1925 else:
1926 session.commit()
1927 if progress:
1928 progress.handle_success(success_msg=success_msg)
1930 finally:
1931 session.close()
1933 ##############################
1934 # configure methods
1935 ##############################
1937 def configure(self, session=None):
1938 """
1939 View for configuring aspects of the app which are pertinent to
1940 this master view and/or model.
1942 By default, this view is included only if :attr:`configurable`
1943 is true. It usually maps to a URL like ``/widgets/configure``.
1945 The expected workflow is as follows:
1947 * user navigates to Configure page
1948 * user modifies settings and clicks Save
1949 * this view then *deletes* all "known" settings
1950 * then it saves user-submitted settings
1952 That is unless ``remove_settings`` is requested, in which case
1953 settings are deleted but then none are saved. The "known"
1954 settings by default include only the "simple" settings.
1956 As a general rule, a particular setting should be configurable
1957 by (at most) one master view. Some settings may never be
1958 exposed at all. But when exposing a setting, careful thought
1959 should be given to where it logically/best belongs.
1961 Some settings are "simple" and a master view subclass need
1962 only provide their basic definitions via
1963 :meth:`configure_get_simple_settings()`. If complex settings
1964 are needed, subclass must override one or more other methods
1965 to achieve the aim(s).
1967 See also related methods, used by this one:
1969 * :meth:`configure_get_simple_settings()`
1970 * :meth:`configure_get_context()`
1971 * :meth:`configure_gather_settings()`
1972 * :meth:`configure_remove_settings()`
1973 * :meth:`configure_save_settings()`
1974 """
1975 self.configuring = True
1976 config_title = self.get_config_title()
1978 # was form submitted?
1979 if self.request.method == "POST":
1981 # maybe just remove settings
1982 if self.request.POST.get("remove_settings"):
1983 self.configure_remove_settings(session=session)
1984 self.request.session.flash(
1985 f"All settings for {config_title} have been removed.", "warning"
1986 )
1988 # reload configure page
1989 return self.redirect(self.request.current_route_url())
1991 # gather/save settings
1992 data = get_form_data(self.request)
1993 settings = self.configure_gather_settings(data)
1994 self.configure_remove_settings(session=session)
1995 self.configure_save_settings(settings, session=session)
1996 self.request.session.flash("Settings have been saved.")
1998 # reload configure page
1999 return self.redirect(self.request.url)
2001 # render configure page
2002 context = self.configure_get_context()
2003 return self.render_to_response("configure", context)
2005 def configure_get_context(
2006 self,
2007 simple_settings=None,
2008 ):
2009 """
2010 Returns the full context dict, for rendering the
2011 :meth:`configure()` page template.
2013 Default context will include ``simple_settings`` (normalized
2014 to just name/value).
2016 You may need to override this method, to add additional
2017 "complex" settings etc.
2019 :param simple_settings: Optional list of simple settings, if
2020 already initialized. Otherwise it is retrieved via
2021 :meth:`configure_get_simple_settings()`.
2023 :returns: Context dict for the page template.
2024 """
2025 context = {}
2027 # simple settings
2028 if simple_settings is None:
2029 simple_settings = self.configure_get_simple_settings()
2030 if simple_settings:
2032 # we got some, so "normalize" each definition to name/value
2033 normalized = {}
2034 for simple in simple_settings:
2036 # name
2037 name = simple["name"]
2039 # value
2040 if "value" in simple:
2041 value = simple["value"]
2042 elif simple.get("type") is bool:
2043 value = self.config.get_bool(
2044 name, default=simple.get("default", False)
2045 )
2046 else:
2047 value = self.config.get(name, default=simple.get("default"))
2049 normalized[name] = value
2051 # add to template context
2052 context["simple_settings"] = normalized
2054 return context
2056 def configure_get_simple_settings(self):
2057 """
2058 This should return a list of "simple" setting definitions for
2059 the :meth:`configure()` view, which can be handled in a more
2060 automatic way. (This is as opposed to some settings which are
2061 more complex and must be handled manually; those should not be
2062 part of this method's return value.)
2064 Basically a "simple" setting is one which can be represented
2065 by a single field/widget on the Configure page.
2067 The setting definitions returned must each be a dict of
2068 "attributes" for the setting. For instance a *very* simple
2069 setting might be::
2071 {'name': 'wutta.app_title'}
2073 The ``name`` is required, everything else is optional. Here
2074 is a more complete example::
2076 {
2077 'name': 'wutta.production',
2078 'type': bool,
2079 'default': False,
2080 'save_if_empty': False,
2081 }
2083 Note that if specified, the ``default`` should be of the same
2084 data type as defined for the setting (``bool`` in the above
2085 example). The default ``type`` is ``str``.
2087 Normally if a setting's value is effectively null, the setting
2088 is removed instead of keeping it in the DB. This behavior can
2089 be changed per-setting via the ``save_if_empty`` flag.
2091 :returns: List of setting definition dicts as described above.
2092 Note that their order does not matter since the template
2093 must explicitly define field layout etc.
2094 """
2095 return []
2097 def configure_gather_settings(
2098 self,
2099 data,
2100 simple_settings=None,
2101 ):
2102 """
2103 Collect the full set of "normalized" settings from user
2104 request, so that :meth:`configure()` can save them.
2106 Settings are gathered from the given request (e.g. POST)
2107 ``data``, but also taking into account what we know based on
2108 the simple setting definitions.
2110 Subclass may need to override this method if complex settings
2111 are required.
2113 :param data: Form data submitted via POST request.
2115 :param simple_settings: Optional list of simple settings, if
2116 already initialized. Otherwise it is retrieved via
2117 :meth:`configure_get_simple_settings()`.
2119 This method must return a list of normalized settings, similar
2120 in spirit to the definition syntax used in
2121 :meth:`configure_get_simple_settings()`. However the format
2122 returned here is minimal and contains just name/value::
2124 {
2125 'name': 'wutta.app_title',
2126 'value': 'Wutta Wutta',
2127 }
2129 Note that the ``value`` will always be a string.
2131 Also note, whereas it's possible ``data`` will not contain all
2132 known settings, the return value *should* (potentially)
2133 contain all of them.
2135 The one exception is when a simple setting has null value, by
2136 default it will not be included in the result (hence, not
2137 saved to DB) unless the setting definition has the
2138 ``save_if_empty`` flag set.
2139 """
2140 settings = []
2142 # simple settings
2143 if simple_settings is None:
2144 simple_settings = self.configure_get_simple_settings()
2145 if simple_settings:
2147 # we got some, so "normalize" each definition to name/value
2148 for simple in simple_settings:
2149 name = simple["name"]
2151 if name in data:
2152 value = data[name]
2153 elif simple.get("type") is bool:
2154 # nb. bool false will be *missing* from data
2155 value = False
2156 else:
2157 value = simple.get("default")
2159 if simple.get("type") is bool:
2160 value = str(bool(value)).lower()
2161 elif simple.get("type") is int:
2162 value = str(int(value or "0"))
2163 elif value is None:
2164 value = ""
2165 else:
2166 value = str(value)
2168 # only want to save this setting if we received a
2169 # value, or if empty values are okay to save
2170 if value or simple.get("save_if_empty"):
2171 settings.append({"name": name, "value": value})
2173 return settings
2175 def configure_remove_settings(
2176 self,
2177 simple_settings=None,
2178 session=None,
2179 ):
2180 """
2181 Remove all "known" settings from the DB; this is called by
2182 :meth:`configure()`.
2184 The point of this method is to ensure *all* "known" settings
2185 which are managed by this master view, are purged from the DB.
2187 The default logic can handle this automatically for simple
2188 settings; subclass must override for any complex settings.
2190 :param simple_settings: Optional list of simple settings, if
2191 already initialized. Otherwise it is retrieved via
2192 :meth:`configure_get_simple_settings()`.
2193 """
2194 names = []
2196 # simple settings
2197 if simple_settings is None:
2198 simple_settings = self.configure_get_simple_settings()
2199 if simple_settings:
2200 names.extend([simple["name"] for simple in simple_settings])
2202 if names:
2203 # nb. must avoid self.Session here in case that does not
2204 # point to our primary app DB
2205 session = session or self.Session()
2206 for name in names:
2207 self.app.delete_setting(session, name)
2209 def configure_save_settings(self, settings, session=None):
2210 """
2211 Save the given settings to the DB; this is called by
2212 :meth:`configure()`.
2214 This method expects a list of name/value dicts and will simply
2215 save each to the DB, with no "conversion" logic.
2217 :param settings: List of normalized setting definitions, as
2218 returned by :meth:`configure_gather_settings()`.
2219 """
2220 # nb. must avoid self.Session here in case that does not point
2221 # to our primary app DB
2222 session = session or self.Session()
2223 for setting in settings:
2224 self.app.save_setting(
2225 session, setting["name"], setting["value"], force_create=True
2226 )
2228 ##############################
2229 # grid rendering methods
2230 ##############################
2232 def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument
2233 """
2234 Custom grid value renderer for "boolean" fields.
2236 This converts a bool value to "Yes" or "No" - unless the value
2237 is ``None`` in which case this renders empty string.
2238 To use this feature for your grid::
2240 grid.set_renderer('my_bool_field', self.grid_render_bool)
2241 """
2242 if value is None:
2243 return None
2245 return "Yes" if value else "No"
2247 def grid_render_currency(self, record, key, value, scale=2):
2248 """
2249 Custom grid value renderer for "currency" fields.
2251 This expects float or decimal values, and will round the
2252 decimal as appropriate, and add the currency symbol.
2254 :param scale: Number of decimal digits to be displayed;
2255 default is 2 places.
2257 To use this feature for your grid::
2259 grid.set_renderer('my_currency_field', self.grid_render_currency)
2261 # you can also override scale
2262 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
2263 """
2265 # nb. get new value since the one provided will just be a
2266 # (json-safe) *string* if the original type was Decimal
2267 value = record[key]
2269 if value is None:
2270 return None
2272 if value < 0:
2273 fmt = f"(${{:0,.{scale}f}})"
2274 return fmt.format(0 - value)
2276 fmt = f"${{:0,.{scale}f}}"
2277 return fmt.format(value)
2279 def grid_render_datetime( # pylint: disable=empty-docstring
2280 self, record, key, value, fmt=None
2281 ):
2282 """ """
2283 warnings.warn(
2284 "MasterView.grid_render_datetime() is deprecated; "
2285 "please use app.render_datetime() directly instead",
2286 DeprecationWarning,
2287 stacklevel=2,
2288 )
2290 # nb. get new value since the one provided will just be a
2291 # (json-safe) *string* if the original type was datetime
2292 value = record[key]
2294 if value is None:
2295 return None
2297 return value.strftime(fmt or "%Y-%m-%d %I:%M:%S %p")
2299 def grid_render_enum(self, record, key, value, enum=None):
2300 """
2301 Custom grid value renderer for "enum" fields.
2303 :param enum: Enum class for the field. This should be an
2304 instance of :class:`~python:enum.Enum`.
2306 To use this feature for your grid::
2308 from enum import Enum
2310 class MyEnum(Enum):
2311 ONE = 1
2312 TWO = 2
2313 THREE = 3
2315 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
2316 """
2317 if enum:
2318 original = record[key]
2319 if original:
2320 return original.name
2322 return value
2324 def grid_render_notes( # pylint: disable=unused-argument
2325 self, record, key, value, maxlen=100
2326 ):
2327 """
2328 Custom grid value renderer for "notes" fields.
2330 If the given text ``value`` is shorter than ``maxlen``
2331 characters, it is returned as-is.
2333 But if it is longer, then it is truncated and an ellispsis is
2334 added. The resulting ``<span>`` tag is also given a ``title``
2335 attribute with the original (full) text, so that appears on
2336 mouse hover.
2338 To use this feature for your grid::
2340 grid.set_renderer('my_notes_field', self.grid_render_notes)
2342 # you can also override maxlen
2343 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
2344 """
2345 if value is None:
2346 return None
2348 if len(value) < maxlen:
2349 return value
2351 return HTML.tag("span", title=value, c=f"{value[:maxlen]}...")
2353 ##############################
2354 # support methods
2355 ##############################
2357 def get_class_hierarchy(self, topfirst=True):
2358 """
2359 Convenience to return a list of classes from which the current
2360 class inherits.
2362 This is a wrapper around
2363 :func:`wuttjamaican.util.get_class_hierarchy()`.
2364 """
2365 return get_class_hierarchy(self.__class__, topfirst=topfirst)
2367 def has_perm(self, name):
2368 """
2369 Shortcut to check if current user has the given permission.
2371 This will automatically add the :attr:`permission_prefix` to
2372 ``name`` before passing it on to
2373 :func:`~wuttaweb.subscribers.request.has_perm()`.
2375 For instance within the
2376 :class:`~wuttaweb.views.users.UserView` these give the same
2377 result::
2379 self.request.has_perm('users.edit')
2381 self.has_perm('edit')
2383 So this shortcut only applies to permissions defined for the
2384 current master view. The first example above must still be
2385 used to check for "foreign" permissions (i.e. any needing a
2386 different prefix).
2387 """
2388 permission_prefix = self.get_permission_prefix()
2389 return self.request.has_perm(f"{permission_prefix}.{name}")
2391 def has_any_perm(self, *names):
2392 """
2393 Shortcut to check if current user has any of the given
2394 permissions.
2396 This calls :meth:`has_perm()` until one returns ``True``. If
2397 none do, returns ``False``.
2398 """
2399 for name in names:
2400 if self.has_perm(name):
2401 return True
2402 return False
2404 def make_button(
2405 self,
2406 label,
2407 variant=None,
2408 primary=False,
2409 url=None,
2410 **kwargs,
2411 ):
2412 """
2413 Make and return a HTML ``<b-button>`` literal.
2415 :param label: Text label for the button.
2417 :param variant: This is the "Buefy type" (or "Oruga variant")
2418 for the button. Buefy and Oruga represent this differently
2419 but this logic expects the Buefy format
2420 (e.g. ``is-danger``) and *not* the Oruga format
2421 (e.g. ``danger``), despite the param name matching Oruga's
2422 terminology.
2424 :param type: This param is not advertised in the method
2425 signature, but if caller specifies ``type`` instead of
2426 ``variant`` it should work the same.
2428 :param primary: If neither ``variant`` nor ``type`` are
2429 specified, this flag may be used to automatically set the
2430 Buefy type to ``is-primary``.
2432 This is the preferred method where applicable, since it
2433 avoids the Buefy vs. Oruga confusion, and the
2434 implementation can change in the future.
2436 :param url: Specify this (instead of ``href``) to make the
2437 button act like a link. This will yield something like:
2438 ``<b-button tag="a" href="{url}">``
2440 :param \\**kwargs: All remaining kwargs are passed to the
2441 underlying ``HTML.tag()`` call, so will be rendered as
2442 attributes on the button tag.
2444 **NB.** You cannot specify a ``tag`` kwarg, for technical
2445 reasons.
2447 :returns: HTML literal for the button element. Will be something
2448 along the lines of:
2450 .. code-block::
2452 <b-button type="is-primary"
2453 icon-pack="fas"
2454 icon-left="hand-pointer">
2455 Click Me
2456 </b-button>
2457 """
2458 btn_kw = kwargs
2459 btn_kw.setdefault("c", label)
2460 btn_kw.setdefault("icon_pack", "fas")
2462 if "type" not in btn_kw:
2463 if variant:
2464 btn_kw["type"] = variant
2465 elif primary:
2466 btn_kw["type"] = "is-primary"
2468 if url:
2469 btn_kw["href"] = url
2471 button = HTML.tag("b-button", **btn_kw)
2473 if url:
2474 # nb. unfortunately HTML.tag() calls its first arg 'tag'
2475 # and so we can't pass a kwarg with that name...so instead
2476 # we patch that into place manually
2477 button = str(button)
2478 button = button.replace("<b-button ", '<b-button tag="a" ')
2479 button = HTML.literal(button)
2481 return button
2483 def make_importer_component(
2484 self, label=None, url=None, orientation="import", single=False, **kwargs
2485 ):
2486 """
2487 Generate HTML for a ``<wutta-model-importer>`` component.
2489 This is really just a convenience but is meant to "centralize"
2490 the logic for doing it. Will return something like:
2492 .. code-block:: html
2494 <wutta-model-importer label="Import from FooSystem"
2495 url="/widgets/import-foosys"
2496 orientation="import" />
2498 :param label: Label for the main button.
2500 :param url: Action URL for the form submit.
2502 :param orientation: Should be ``"import"`` (default) or
2503 ``"export"``. As of writing this is only used to determine
2504 which icon to give the button.
2506 :param single: Whether the component is for a
2507 importing/exporting a "single" record as opposed to the
2508 "full" table. Defaults to the latter. This controls which
2509 params are exposed for the user when they initiate a run.
2511 :returns: Rendered HTML
2512 """
2513 kwargs.setdefault(":single", "true" if single else "false")
2514 return HTML.tag(
2515 "wutta-model-importer",
2516 orientation=orientation,
2517 label=label,
2518 url=url,
2519 **kwargs,
2520 )
2522 def get_xref_buttons(self, obj): # pylint: disable=unused-argument
2523 """
2524 Should return a list of "cross-reference" buttons to be shown
2525 when viewing the given object.
2527 Default logic always returns empty list; subclass can override
2528 as needed.
2530 If applicable, this method should do its own permission checks
2531 and only include the buttons current user should be allowed to
2532 see/use.
2534 See also :meth:`make_button()` - example::
2536 def get_xref_buttons(self, product):
2537 buttons = []
2538 if self.request.has_perm('external_products.view'):
2539 url = self.request.route_url('external_products.view',
2540 id=product.external_id)
2541 buttons.append(self.make_button("View External", url=url))
2542 return buttons
2543 """
2544 return []
2546 def make_progress(self, key, **kwargs):
2547 """
2548 Create and return a
2549 :class:`~wuttaweb.progress.SessionProgress` instance, with the
2550 given key.
2552 This is normally done just before calling
2553 :meth:`render_progress()`.
2554 """
2555 return SessionProgress(self.request, key, **kwargs)
2557 def render_progress(self, progress, context=None, template=None):
2558 """
2559 Render the progress page, with given template/context.
2561 When a view method needs to start a long-running operation, it
2562 first starts a thread to do the work, and then it renders the
2563 "progress" page. As the operation continues the progress page
2564 is updated. When the operation completes (or fails) the user
2565 is redirected to the final destination.
2567 TODO: should document more about how to do this..
2569 :param progress: Progress indicator instance as returned by
2570 :meth:`make_progress()`.
2572 :returns: A :term:`response` with rendered progress page.
2573 """
2574 template = template or "/progress.mako"
2575 context = context or {}
2576 context["progress"] = progress
2577 return render_to_response(template, context, request=self.request)
2579 def get_template_context(self, context): # pylint: disable=too-many-branches
2580 """
2581 Collect the "complete" context for rendering the current
2582 template. This is called from :meth:`render_to_response()`.
2584 The default logic should be suitable for many cases but
2585 subclass is expected to override if needed.
2587 This one method is used for index, create, view, edit, etc.
2588 The actual template file is not known at this point, so cannot
2589 be inspected. But you can inspect :attr:`listing`,
2590 :attr:`editing` (etc.) instead, or even inspect e.g. the
2591 request :attr:`~pyramid.request.Request.matched_route` for the
2592 "best" clue as to which template is involved.
2594 This adds several things to the context, but the
2595 caller-provided context values are always preferred. In all
2596 cases these are added if not already present:
2598 * ``master`` - reference to the current master class instance
2599 * ``route_prefix`` - value from :meth:`get_route_prefix()`
2600 * ``index_title`` - value from :meth:`get_index_title()`
2601 * ``index_url`` - value from :meth:`get_index_url()`
2602 * ``model_title`` - value from :meth:`get_model_title()`
2603 * ``model_title_plural`` - value from :meth:`get_model_title_plural()`
2604 * ``config_title`` - value from :meth:`get_config_title()`
2606 If the context already contains an ``instance`` value, several
2607 more things may be added:
2609 * ``instance_title`` - value from :meth:`get_instance_title()`
2610 * ``url_view_instance`` - URL to view the instance
2611 * ``instance_editable`` - whether to show the Edit button
2612 * ``url_edit_instance`` - URL to edit the instance
2613 * ``instance_deletable`` - whether to show the Delete button
2614 * ``url_delete_instance`` - URL to delete the instance
2616 .. note::
2617 The templates generally expect an "instance" to be the
2618 "current thing" e.g. in a CRUD scenario. This is of course
2619 a :attr:`model_class` instance most of the time, but it can
2620 also be a :attr:`row_model_class` instance.
2622 This can be confusing, hence this note... I'm not sure
2623 if/how the situation can be improved, since sometimes we
2624 need to think of a row as a separate concept, but some CRUD
2625 logic benefits from being more "agnostic" and not really
2626 caring if it has a "parent" or "child" object to deal with.
2628 :param context: Original context dict provided by view
2629 callable.
2631 :returns: Final context dict for the template.
2632 """
2633 defaults = {
2634 "master": self,
2635 "route_prefix": self.get_route_prefix(),
2636 "index_title": self.get_index_title(),
2637 "index_url": self.get_index_url(),
2638 "model_title": self.get_model_title(),
2639 "model_title_plural": self.get_model_title_plural(),
2640 "config_title": self.get_config_title(),
2641 }
2643 # merge defaults + caller-provided context
2644 defaults.update(context)
2645 context = defaults
2647 # add crud flags, URLs etc. if we have an instance
2648 if "instance" in context:
2649 instance = context["instance"]
2650 if is_row := self.has_rows and "parent_instance" in context:
2651 parent = context["parent_instance"]
2653 if "instance_title" not in context:
2654 if is_row:
2655 context["instance_title"] = self.get_row_instance_title(instance)
2656 else:
2657 context["instance_title"] = self.get_instance_title(instance)
2659 if is_row:
2660 context.setdefault(
2661 "url_view_instance",
2662 self.get_row_action_url("view_row", instance, parent=parent),
2663 )
2664 else:
2665 context.setdefault(
2666 "url_view_instance", self.get_action_url("view", instance)
2667 )
2669 if "instance_editable" not in context:
2670 if is_row:
2671 context["instance_editable"] = (
2672 self.rows_editable
2673 and self.has_perm("edit_row")
2674 and self.is_row_editable(instance)
2675 )
2676 else:
2677 context["instance_editable"] = (
2678 self.editable
2679 and self.has_perm("edit")
2680 and self.is_editable(instance)
2681 )
2683 if context["instance_editable"]:
2684 if is_row:
2685 context.setdefault(
2686 "url_edit_instance",
2687 self.get_row_action_url("edit_row", instance, parent=parent),
2688 )
2689 else:
2690 context.setdefault(
2691 "url_edit_instance", self.get_action_url("edit", instance)
2692 )
2694 if "instance_deletable" not in context:
2695 if is_row:
2696 context["instance_deletable"] = (
2697 self.rows_deletable
2698 and self.has_perm("delete_row")
2699 and self.is_row_deletable(instance)
2700 )
2701 else:
2702 context["instance_deletable"] = (
2703 self.deletable
2704 and self.has_perm("delete")
2705 and self.is_deletable(instance)
2706 )
2708 if context["instance_deletable"]:
2709 if is_row:
2710 context.setdefault(
2711 "url_delete_instance",
2712 self.get_row_action_url("delete_row", instance, parent=parent),
2713 )
2714 else:
2715 context.setdefault(
2716 "url_delete_instance", self.get_action_url("delete", instance)
2717 )
2719 return context
2721 def render_to_response(self, template, context):
2722 """
2723 Locate and render the appropriate template, and return the
2724 final HTTP :term:`response`.
2726 This first calls :meth:`get_template_context()` and then tries
2727 to locate the template.
2729 The specified ``template`` should be only the "base name" for
2730 the template - e.g. ``"index"`` or ``"edit"``. This is fed
2731 to :meth:`get_page_templates()` to obtain the list of
2732 candidates. It then tries to render each until one succeeds.
2734 In practice this *usually* means two different template paths
2735 will be attempted, e.g. if ``template`` is ``"edit"`` and
2736 :attr:`template_prefix` is ``"/widgets"``:
2738 * ``/widgets/edit.mako``
2739 * ``/master/edit.mako``
2741 Pyramid's
2742 :func:`~pyramid:pyramid.renderers.render_to_response()`
2743 handles the real work beyond that.
2745 :param template: Base name for the template.
2747 :param context: Initial template context from the view
2748 callable. This will be fed to
2749 :meth:`get_template_context()` to obtain the final/full
2750 context.
2752 :returns: :class:`~pyramid.response.Response` object for the
2753 rendered template.
2755 :raises IOError: If no template can be found.
2756 """
2757 context = self.get_template_context(context)
2759 # first try the template path most specific to this view
2760 page_templates = self.get_page_templates(template)
2761 mako_path = page_templates[0]
2762 try:
2763 return render_to_response(mako_path, context, request=self.request)
2764 except IOError:
2766 # failing that, try one or more fallback templates
2767 for fallback in page_templates[1:]:
2768 try:
2769 return render_to_response(fallback, context, request=self.request)
2770 except IOError:
2771 pass
2773 # if we made it all the way here, then we found no
2774 # templates at all, in which case re-attempt the first and
2775 # let that error raise on up
2776 return render_to_response(mako_path, context, request=self.request)
2778 def get_page_templates(self, template):
2779 """
2780 Returns a list of all templates which can be attempted, to
2781 render the current page. This is called by
2782 :meth:`render_to_response()`.
2784 The list should be in order of preference, e.g. the first
2785 entry will be the most "specific" template, with subsequent
2786 entries becoming more generic.
2788 In practice this method defines the first entry but calls
2789 :meth:`get_fallback_templates()` for the rest.
2791 :param template: Base name for a template (without prefix), e.g.
2792 ``'view'``.
2794 :returns: List of template paths to be tried, based on the
2795 specified template. For instance if ``template`` is
2796 ``'view'`` this will (by default) return::
2798 [
2799 '/widgets/view.mako',
2800 '/master/view.mako',
2801 ]
2803 """
2804 template_prefix = self.get_template_prefix()
2805 page_templates = [f"{template_prefix}/{template}.mako"]
2806 page_templates.extend(self.get_fallback_templates(template))
2807 return page_templates
2809 def get_fallback_templates(self, template):
2810 """
2811 Returns a list of "fallback" template paths which may be
2812 attempted for rendering the current page. See also
2813 :meth:`get_page_templates()`.
2815 :param template: Base name for a template (without prefix), e.g.
2816 ``'view'``.
2818 :returns: List of template paths to be tried, based on the
2819 specified template. For instance if ``template`` is
2820 ``'view'`` this will (by default) return::
2822 ['/master/view.mako']
2823 """
2824 return [f"/master/{template}.mako"]
2826 def get_index_title(self):
2827 """
2828 Returns the main index title for the master view.
2830 By default this returns the value from
2831 :meth:`get_model_title_plural()`. Subclass may override as
2832 needed.
2833 """
2834 return self.get_model_title_plural()
2836 def get_index_url(self, **kwargs):
2837 """
2838 Returns the URL for master's :meth:`index()` view.
2840 NB. this returns ``None`` if :attr:`listable` is false.
2841 """
2842 if self.listable:
2843 route_prefix = self.get_route_prefix()
2844 return self.request.route_url(route_prefix, **kwargs)
2845 return None
2847 def set_labels(self, obj):
2848 """
2849 Set label overrides on a form or grid, based on what is
2850 defined by the view class and its parent class(es).
2852 This is called automatically from :meth:`configure_grid()` and
2853 :meth:`configure_form()`.
2855 This calls :meth:`collect_labels()` to find everything, then
2856 it assigns the labels using one of (based on ``obj`` type):
2858 * :func:`wuttaweb.forms.base.Form.set_label()`
2859 * :func:`wuttaweb.grids.base.Grid.set_label()`
2861 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
2862 :class:`~wuttaweb.forms.base.Form` instance.
2863 """
2864 labels = self.collect_labels()
2865 for key, label in labels.items():
2866 obj.set_label(key, label)
2868 def collect_labels(self):
2869 """
2870 Collect all labels defined by the view class and/or its parents.
2872 A master view can declare labels via class-level attribute,
2873 like so::
2875 from wuttaweb.views import MasterView
2877 class WidgetView(MasterView):
2879 labels = {
2880 'id': "Widget ID",
2881 'serial_no': "Serial Number",
2882 }
2884 All such labels, defined by any class from which the master
2885 view inherits, will be returned. However if the same label
2886 key is defined by multiple classes, the "subclass" always
2887 wins.
2889 Labels defined in this way will apply to both forms and grids.
2890 See also :meth:`set_labels()`.
2892 :returns: Dict of all labels found.
2893 """
2894 labels = {}
2895 hierarchy = self.get_class_hierarchy()
2896 for cls in hierarchy:
2897 if hasattr(cls, "labels"):
2898 labels.update(cls.labels)
2899 return labels
2901 def make_model_grid(
2902 self, session=None, **kwargs
2903 ): # pylint: disable=too-many-branches,too-many-statements
2904 """
2905 Create the main grid for use with the :meth:`index()` view.
2907 This may call several other methods to collect the grid
2908 constructor kwargs etc.:
2910 * :meth:`get_grid_key()`
2911 * :meth:`get_grid_columns()`
2912 * :meth:`get_grid_data()`
2913 * :meth:`get_action_url_view()`
2914 * :meth:`get_action_url_edit()`
2915 * :meth:`get_action_url_delete()`
2916 * :meth:`~wuttaweb.views.base.View.make_grid()`
2917 * :meth:`configure_grid()`
2919 Before returning the grid this also calls its
2920 :meth:`~wuttaweb.grids.base.Grid.load_settings()` method.
2922 :param session: Optional :term:`db session` for use with
2923 :meth:`get_grid_data()`. If not specified then
2924 :attr:`Session` will be assumed.
2926 :param \\**kwargs: Override and/or additional kwargs for the
2927 grid constructor.
2929 :returns: :class:`~wuttaweb.grids.base.Grid` instance
2930 """
2931 route_prefix = self.get_route_prefix()
2933 if "key" not in kwargs:
2934 kwargs["key"] = self.get_grid_key()
2936 if "model_class" not in kwargs:
2937 model_class = self.get_model_class()
2938 if model_class:
2939 kwargs["model_class"] = model_class
2941 if "columns" not in kwargs:
2942 kwargs["columns"] = self.get_grid_columns()
2944 if "data" not in kwargs:
2945 kwargs["data"] = self.get_grid_data(
2946 columns=kwargs["columns"], session=session
2947 )
2949 if "actions" not in kwargs:
2950 actions = []
2952 # TODO: should split this off into index_get_grid_actions() ?
2954 if self.viewable and self.has_perm("view"):
2955 actions.append(
2956 self.make_grid_action(
2957 "view", icon="eye", url=self.get_action_url_view
2958 )
2959 )
2961 if self.editable and self.has_perm("edit"):
2962 actions.append(
2963 self.make_grid_action(
2964 "edit", icon="edit", url=self.get_action_url_edit
2965 )
2966 )
2968 if self.deletable and self.has_perm("delete"):
2969 actions.append(
2970 self.make_grid_action(
2971 "delete",
2972 icon="trash",
2973 url=self.get_action_url_delete,
2974 link_class="has-text-danger",
2975 )
2976 )
2978 kwargs["actions"] = actions
2980 mergeable = self.mergeable and self.has_perm("merge")
2982 if "tools" not in kwargs:
2983 tools = []
2985 # show totals
2986 if self.has_grid_totals:
2987 button = self.make_button(
2988 "{{ gridTotalsFetching ? 'Working, please wait...' : 'Show Totals' }}",
2989 icon_left="calculator",
2990 **{
2991 "v-if": "!gridTotalsHTML.length",
2992 ":disabled": "gridTotalsFetching",
2993 "@click": "gridTotalsFetch()",
2994 },
2995 )
2996 display = HTML.tag(
2997 "div",
2998 class_="control",
2999 style="margin: 0 0.5rem;",
3000 **{
3001 "v-if": "gridTotalsHTML.length",
3002 "v-html": "gridTotalsHTML",
3003 },
3004 )
3005 wrapper = HTML.tag("div", c=[button, display])
3006 tools.append(("show-totals", wrapper))
3008 # delete-bulk
3009 if self.deletable_bulk and self.has_perm("delete_bulk"):
3010 tools.append(("delete-results", self.delete_bulk_make_button()))
3012 # merge
3013 if mergeable:
3014 hidden = tags.hidden("uuids", **{":value": "checkedUUIDs"})
3015 button = self.make_button(
3016 '{{ mergeSubmitting ? "Working, please wait..." : "Merge 2 records" }}',
3017 primary=True,
3018 native_type="submit",
3019 icon_left="object-ungroup",
3020 **{":disabled": "mergeSubmitting || checkedRows.length != 2"},
3021 )
3022 csrf = render_csrf_token(self.request)
3023 html = (
3024 tags.form(
3025 self.request.route_url(f"{route_prefix}.merge"),
3026 **{"@submit": "mergeSubmitting = true"},
3027 )
3028 + csrf
3029 + hidden
3030 + button
3031 + tags.end_form()
3032 )
3033 tools.append(("merge", html))
3035 kwargs["tools"] = tools
3037 kwargs.setdefault("checkable", self.checkable or mergeable)
3038 if hasattr(self, "grid_row_class"):
3039 kwargs.setdefault("row_class", self.grid_row_class)
3040 kwargs.setdefault("filterable", self.filterable)
3041 kwargs.setdefault("filter_defaults", self.filter_defaults)
3042 kwargs.setdefault("sortable", self.sortable)
3043 kwargs.setdefault("sort_on_backend", self.sort_on_backend)
3044 kwargs.setdefault("sort_defaults", self.sort_defaults)
3045 kwargs.setdefault("paginated", self.paginated)
3046 kwargs.setdefault("paginate_on_backend", self.paginate_on_backend)
3048 grid = self.make_grid(**kwargs)
3049 self.configure_grid(grid)
3050 grid.load_settings()
3051 return grid
3053 def get_grid_columns(self):
3054 """
3055 Returns the default list of grid column names, for the
3056 :meth:`index()` view.
3058 This is called by :meth:`make_model_grid()`; in the resulting
3059 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
3060 :attr:`~wuttaweb.grids.base.Grid.columns`.
3062 This method may return ``None``, in which case the grid may
3063 (try to) generate its own default list.
3065 Subclass may define :attr:`grid_columns` for simple cases, or
3066 can override this method if needed.
3068 Also note that :meth:`configure_grid()` may be used to further
3069 modify the final column set, regardless of what this method
3070 returns. So a common pattern is to declare all "supported"
3071 columns by setting :attr:`grid_columns` but then optionally
3072 remove or replace some of those within
3073 :meth:`configure_grid()`.
3074 """
3075 if hasattr(self, "grid_columns"):
3076 return self.grid_columns
3077 return None
3079 def get_grid_data( # pylint: disable=unused-argument
3080 self, columns=None, session=None
3081 ):
3082 """
3083 Returns the grid data for the :meth:`index()` view.
3085 This is called by :meth:`make_model_grid()`; in the resulting
3086 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
3087 :attr:`~wuttaweb.grids.base.Grid.data`.
3089 Default logic will call :meth:`get_query()` and if successful,
3090 return the list from ``query.all()``. Otherwise returns an
3091 empty list. Subclass should override as needed.
3092 """
3093 query = self.get_query(session=session)
3094 if query:
3095 return query
3096 return []
3098 def get_query(self, session=None):
3099 """
3100 Returns the main SQLAlchemy query object for the
3101 :meth:`index()` view. This is called by
3102 :meth:`get_grid_data()`.
3104 Default logic for this method returns a "plain" query on the
3105 :attr:`model_class` if that is defined; otherwise ``None``.
3106 """
3107 model_class = self.get_model_class()
3108 if model_class:
3109 session = session or self.Session()
3110 return session.query(model_class)
3111 return None
3113 def configure_grid(self, grid):
3114 """
3115 Configure the grid for the :meth:`index()` view.
3117 This is called by :meth:`make_model_grid()`.
3119 There is minimal default logic here; subclass should override
3120 as needed. The ``grid`` param will already be "complete" and
3121 ready to use as-is, but this method can further modify it
3122 based on request details etc.
3123 """
3124 if "uuid" in grid.columns:
3125 grid.columns.remove("uuid")
3127 self.set_labels(grid)
3129 # TODO: i thought this was a good idea but if so it
3130 # needs a try/catch in case of no model class
3131 # for key in self.get_model_key():
3132 # grid.set_link(key)
3134 def get_instance(self, session=None, matchdict=None):
3135 """
3136 This should return the appropriate model instance, based on
3137 the ``matchdict`` of model keys.
3139 Normally this is called with no arguments, in which case the
3140 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
3141 will return the "current" model instance based on the request
3142 (route/params).
3144 If a ``matchdict`` is provided then that is used instead, to
3145 obtain the model keys. In the simple/common example of a
3146 "native" model in WuttaWeb, this would look like::
3148 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
3149 obj = self.get_instance(matchdict=keys)
3151 Although some models may have different, possibly composite
3152 key names to use instead. The specific keys this logic is
3153 expecting are the same as returned by :meth:`get_model_key()`.
3155 If this method is unable to locate the instance, it should
3156 raise a 404 error,
3157 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
3159 Default implementation of this method should work okay for
3160 views which define a :attr:`model_class`. For other views
3161 however it will raise ``NotImplementedError``, so subclass
3162 may need to define.
3164 .. warning::
3166 If you are defining this method for a subclass, please note
3167 this point regarding the 404 "not found" logic.
3169 It is *not* enough to simply *return* this 404 response,
3170 you must explicitly *raise* the error. For instance::
3172 def get_instance(self, **kwargs):
3174 # ..try to locate instance..
3175 obj = self.locate_instance_somehow()
3177 if not obj:
3179 # NB. THIS MAY NOT WORK AS EXPECTED
3180 #return self.notfound()
3182 # nb. should always do this in get_instance()
3183 raise self.notfound()
3185 This lets calling code not have to worry about whether or
3186 not this method might return ``None``. It can safely
3187 assume it will get back a model instance, or else a 404
3188 will kick in and control flow goes elsewhere.
3189 """
3190 model_class = self.get_model_class()
3191 if model_class:
3192 session = session or self.Session()
3193 matchdict = matchdict or self.request.matchdict
3195 def filtr(query, model_key):
3196 key = matchdict[model_key]
3197 query = query.filter(getattr(self.model_class, model_key) == key)
3198 return query
3200 query = session.query(model_class)
3202 for key in self.get_model_key():
3203 query = filtr(query, key)
3205 try:
3206 return query.one()
3207 except orm.exc.NoResultFound:
3208 pass
3210 raise self.notfound()
3212 raise NotImplementedError(
3213 "you must define get_instance() method "
3214 f" for view class: {self.__class__}"
3215 )
3217 def get_instance_title(self, instance):
3218 """
3219 Return the human-friendly "title" for the instance, to be used
3220 in the page title when viewing etc.
3222 Default logic returns the value from ``str(instance)``;
3223 subclass may override if needed.
3224 """
3225 return str(instance) or "(no title)"
3227 def get_action_route_kwargs(self, obj):
3228 """
3229 Get a dict of route kwargs for the given object.
3231 This is called from :meth:`get_action_url()` and must return
3232 kwargs suitable for use with ``request.route_url()``.
3234 In practice this should return a dict which has keys for each
3235 field from :meth:`get_model_key()` and values which come from
3236 the object.
3238 :param obj: Model instance object.
3240 :returns: The dict of route kwargs for the object.
3241 """
3242 try:
3243 return {key: obj[key] for key in self.get_model_key()}
3244 except TypeError:
3245 return {key: getattr(obj, key) for key in self.get_model_key()}
3247 def get_action_url(self, action, obj, **kwargs):
3248 """
3249 Generate an "action" URL for the given model instance.
3251 This is a shortcut which generates a route name based on
3252 :meth:`get_route_prefix()` and the ``action`` param.
3254 It calls :meth:`get_action_route_kwargs()` and then passes
3255 those along with route name to ``request.route_url()``, and
3256 returns the result.
3258 :param action: String name for the action, which corresponds
3259 to part of some named route, e.g. ``'view'`` or ``'edit'``.
3261 :param obj: Model instance object.
3263 :param \\**kwargs: Additional kwargs to be passed to
3264 ``request.route_url()``, if needed.
3265 """
3266 kw = self.get_action_route_kwargs(obj)
3267 kw.update(kwargs)
3268 route_prefix = self.get_route_prefix()
3269 return self.request.route_url(f"{route_prefix}.{action}", **kw)
3271 def get_action_url_view(self, obj, i): # pylint: disable=unused-argument
3272 """
3273 Returns the "view" grid action URL for the given object.
3275 Most typically this is like ``/widgets/XXX`` where ``XXX``
3276 represents the object's key/ID.
3278 Calls :meth:`get_action_url()` under the hood.
3279 """
3280 return self.get_action_url("view", obj)
3282 def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument
3283 """
3284 Returns the "edit" grid action URL for the given object, if
3285 applicable.
3287 Most typically this is like ``/widgets/XXX/edit`` where
3288 ``XXX`` represents the object's key/ID.
3290 This first calls :meth:`is_editable()` and if that is false,
3291 this method will return ``None``.
3293 Calls :meth:`get_action_url()` to generate the true URL.
3294 """
3295 if self.is_editable(obj):
3296 return self.get_action_url("edit", obj)
3297 return None
3299 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument
3300 """
3301 Returns the "delete" grid action URL for the given object, if
3302 applicable.
3304 Most typically this is like ``/widgets/XXX/delete`` where
3305 ``XXX`` represents the object's key/ID.
3307 This first calls :meth:`is_deletable()` and if that is false,
3308 this method will return ``None``.
3310 Calls :meth:`get_action_url()` to generate the true URL.
3311 """
3312 if self.is_deletable(obj):
3313 return self.get_action_url("delete", obj)
3314 return None
3316 def is_editable(self, obj): # pylint: disable=unused-argument
3317 """
3318 Returns a boolean indicating whether "edit" should be allowed
3319 for the given model instance (and for current user).
3321 By default this always return ``True``; subclass can override
3322 if needed.
3324 Note that the use of this method implies :attr:`editable` is
3325 true, so the method does not need to check that flag.
3326 """
3327 return True
3329 def is_deletable(self, obj): # pylint: disable=unused-argument
3330 """
3331 Returns a boolean indicating whether "delete" should be
3332 allowed for the given model instance (and for current user).
3334 By default this always return ``True``; subclass can override
3335 if needed.
3337 Note that the use of this method implies :attr:`deletable` is
3338 true, so the method does not need to check that flag.
3339 """
3340 return True
3342 def make_model_form(self, model_instance=None, fields=None, **kwargs):
3343 """
3344 Make a form for the "model" represented by this subclass.
3346 This method is normally called by all CRUD views:
3348 * :meth:`create()`
3349 * :meth:`view()`
3350 * :meth:`edit()`
3351 * :meth:`delete()`
3353 The form need not have a ``model_instance``, as in the case of
3354 :meth:`create()`. And it can be readonly as in the case of
3355 :meth:`view()` and :meth:`delete()`.
3357 If ``fields`` are not provided, :meth:`get_form_fields()` is
3358 called. Usually a subclass will define :attr:`form_fields`
3359 but it's only required if :attr:`model_class` is not set.
3361 Then :meth:`configure_form()` is called, so subclass can go
3362 crazy with that as needed.
3364 :param model_instance: Model instance/record with which to
3365 initialize the form data. Not needed for "create" forms.
3367 :param fields: Optional fields list for the form.
3369 :returns: :class:`~wuttaweb.forms.base.Form` instance
3370 """
3371 if "model_class" not in kwargs:
3372 model_class = self.get_model_class()
3373 if model_class:
3374 kwargs["model_class"] = model_class
3376 kwargs["model_instance"] = model_instance
3378 if not fields:
3379 fields = self.get_form_fields()
3380 if fields:
3381 kwargs["fields"] = fields
3383 form = self.make_form(**kwargs)
3384 self.configure_form(form)
3385 return form
3387 def get_form_fields(self):
3388 """
3389 Returns the initial list of field names for the model form.
3391 This is called by :meth:`make_model_form()`; in the resulting
3392 :class:`~wuttaweb.forms.base.Form` instance, this becomes
3393 :attr:`~wuttaweb.forms.base.Form.fields`.
3395 This method may return ``None``, in which case the form may
3396 (try to) generate its own default list.
3398 Subclass may define :attr:`form_fields` for simple cases, or
3399 can override this method if needed.
3401 Note that :meth:`configure_form()` may be used to further
3402 modify the final field list, regardless of what this method
3403 returns. So a common pattern is to declare all "supported"
3404 fields by setting :attr:`form_fields` but then optionally
3405 remove or replace some in :meth:`configure_form()`.
3406 """
3407 if hasattr(self, "form_fields"):
3408 return self.form_fields
3409 return None
3411 def configure_form(self, form):
3412 """
3413 Configure the given model form, as needed.
3415 This is called by :meth:`make_model_form()` - for multiple
3416 CRUD views (create, view, edit, delete, possibly others).
3418 The default logic here does just one thing: when "editing"
3419 (i.e. in :meth:`edit()` view) then all fields which are part
3420 of the :attr:`model_key` will be marked via
3421 :meth:`set_readonly()` so the user cannot change primary key
3422 values for a record.
3424 Subclass may override as needed. The ``form`` param will
3425 already be "complete" and ready to use as-is, but this method
3426 can further modify it based on request details etc.
3427 """
3428 form.remove("uuid")
3430 self.set_labels(form)
3432 # mark key fields as readonly to prevent edit. see also
3433 # related comments in the objectify() method
3434 if self.editing:
3435 for key in self.get_model_key():
3436 form.set_readonly(key)
3438 def objectify(self, form):
3439 """
3440 Must return a "model instance" object which reflects the
3441 validated form data.
3443 In simple cases this may just return the
3444 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
3446 When dealing with SQLAlchemy models it would return a proper
3447 mapped instance, creating it if necessary.
3449 This is called by various other form-saving methods:
3451 * :meth:`save_create_form()`
3452 * :meth:`save_edit_form()`
3453 * :meth:`create_row_save_form()`
3455 See also :meth:`persist()`.
3457 :param form: Reference to the *already validated*
3458 :class:`~wuttaweb.forms.base.Form` object. See the form's
3459 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
3460 the data.
3461 """
3463 # ColanderAlchemy schema has an objectify() method which will
3464 # return a populated model instance
3465 schema = form.get_schema()
3466 if hasattr(schema, "objectify"):
3467 return schema.objectify(form.validated, context=form.model_instance)
3469 # at this point we likely have no model class, so have to
3470 # assume we're operating on a simple dict record. we (mostly)
3471 # want to return that as-is, unless subclass overrides.
3472 data = dict(form.validated)
3474 # nb. we have a unique scenario when *editing* for a simple
3475 # dict record (no model class). we mark the key fields as
3476 # readonly in configure_form(), so they aren't part of the
3477 # data here, but we need to add them back for sake of
3478 # e.g. generating the 'view' route kwargs for redirect.
3479 if self.editing:
3480 obj = self.get_instance()
3481 for key in self.get_model_key():
3482 if key not in data:
3483 data[key] = obj[key]
3485 return data
3487 def persist(self, obj, session=None):
3488 """
3489 If applicable, this method should persist ("save") the given
3490 object's data (e.g. to DB), creating or updating it as needed.
3492 This is part of the "submit form" workflow; ``obj`` should be
3493 a model instance which already reflects the validated form
3494 data.
3496 Note that there is no default logic here, subclass must
3497 override if needed.
3499 :param obj: Model instance object as produced by
3500 :meth:`objectify()`.
3502 See also :meth:`save_create_form()` and
3503 :meth:`save_edit_form()`, which call this method.
3504 """
3505 model = self.app.model
3506 model_class = self.get_model_class()
3507 if model_class and issubclass(model_class, model.Base):
3509 # add sqlalchemy model to session
3510 session = session or self.Session()
3511 session.add(obj)
3513 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments
3514 self, func, args, kwargs, onerror=None, session=None, progress=None
3515 ):
3516 """
3517 Generic method to invoke for thread operations.
3519 :param func: Callable which performs the actual logic. This
3520 will be wrapped with a try/except statement for error
3521 handling.
3523 :param args: Tuple of positional arguments to pass to the
3524 ``func`` callable.
3526 :param kwargs: Dict of keyword arguments to pass to the
3527 ``func`` callable.
3529 :param onerror: Optional callback to invoke if ``func`` raises
3530 an error. It should not expect any arguments.
3532 :param session: Optional :term:`db session` in effect. Note
3533 that if supplied, it will be *committed* (or rolled back on
3534 error) and *closed* by this method. If you need more
3535 specialized handling, do not use this method (or don't
3536 specify the ``session``).
3538 :param progress: Optional progress factory. If supplied, this
3539 is assumed to be a
3540 :class:`~wuttaweb.progress.SessionProgress` instance, and
3541 it will be updated per success or failure of ``func``
3542 invocation.
3543 """
3544 try:
3545 func(*args, **kwargs)
3547 except Exception as error: # pylint: disable=broad-exception-caught
3548 if session:
3549 session.rollback()
3550 if onerror:
3551 onerror()
3552 else:
3553 log.warning("failed to invoke thread callable: %s", func, exc_info=True)
3554 if progress:
3555 progress.handle_error(error)
3557 else:
3558 if session:
3559 session.commit()
3560 if progress:
3561 progress.handle_success()
3563 finally:
3564 if session:
3565 session.close()
3567 ##############################
3568 # merge methods
3569 ##############################
3571 def merge(self):
3572 """
3573 View for merging two records.
3575 By default, this view is included only if :attr:`mergeable` is
3576 true. It usually maps to a URL like ``/widgets/merge``.
3578 A POST request must be used for this view; otherwise it will
3579 redirect to the :meth:`index()` view. The POST data must
3580 specify a ``uuids`` param string in
3581 ``"removing_uuid,keeping_uuid"`` format.
3583 The user is first shown a "diff" with the
3584 removing/keeping/final data records, as simple preview. They
3585 can swap removing vs. keeping if needed, and when satisfied
3586 they can "execute" the merge.
3588 See also related methods, used by this one:
3590 * :meth:`merge_validate_and_execute()`
3591 * :meth:`merge_get_data()`
3592 * :meth:`merge_get_final_data()`
3593 """
3594 if self.request.method != "POST":
3595 return self.redirect(self.get_index_url())
3597 session = self.Session()
3598 model_class = self.get_model_class()
3600 # load records to be kept/removed
3601 removing = keeping = None
3602 uuids = self.request.POST.get("uuids", "").split(",")
3603 if len(uuids) == 2:
3604 uuid1, uuid2 = uuids
3605 try:
3606 uuid1 = UUID(uuid1)
3607 uuid2 = UUID(uuid2)
3608 except ValueError:
3609 pass
3610 else:
3611 removing = session.get(model_class, uuid1)
3612 keeping = session.get(model_class, uuid2)
3614 # redirect to listing if record(s) not found
3615 if not (removing and keeping):
3616 raise self.redirect(self.get_index_url())
3618 # maybe execute merge
3619 if self.request.POST.get("execute-merge") == "true":
3620 if self.merge_validate_and_execute(removing, keeping):
3621 return self.redirect(self.get_action_url("view", keeping))
3623 removing_data = self.merge_get_data(removing)
3624 keeping_data = self.merge_get_data(keeping)
3625 diff = MergeDiff(
3626 self.config,
3627 removing_data,
3628 keeping_data,
3629 self.merge_get_final_data(removing_data, keeping_data),
3630 fields=self.merge_get_all_fields(),
3631 )
3633 context = {"removing": removing, "keeping": keeping, "diff": diff}
3634 return self.render_to_response("merge", context)
3636 def merge_get_simple_fields(self):
3637 """
3638 Return the list of "simple" fields for the merge.
3640 These "simple" fields will not have any special handling for
3641 the merge. In other words the "removing" record values will
3642 be ignored and the "keeping" record values will remain in
3643 place, without modification.
3645 If the view class defines :attr:`merge_simple_fields`, that
3646 list is returned as-is. Otherwise the list of columns from
3647 :attr:`model_class` is returned.
3649 :returns: List of simple field names.
3650 """
3651 if self.merge_simple_fields:
3652 return list(self.merge_simple_fields)
3654 mapper = sa.inspect(self.get_model_class())
3655 fields = mapper.columns.keys()
3656 return fields
3658 def merge_get_additive_fields(self):
3659 """
3660 Return the list of "additive" fields for the merge.
3662 Values from the removing/keeping record will be conceptually
3663 added together, for each of these fields.
3665 If the view class defines :attr:`merge_additive_fields`, that
3666 list is returned as-is. Otherwise an empty list is returned.
3668 :returns: List of additive field names.
3669 """
3670 if self.merge_additive_fields:
3671 return list(self.merge_additive_fields)
3672 return []
3674 def merge_get_coalesce_fields(self):
3675 """
3676 Return the list of "coalesce" fields for the merge.
3678 Values from the removing/keeping record will be conceptually
3679 "coalesced" for each of these fields.
3681 If the view class defines :attr:`merge_coalesce_fields`, that
3682 list is returned as-is. Otherwise an empty list is returned.
3684 :returns: List of coalesce field names.
3685 """
3686 if self.merge_coalesce_fields:
3687 return list(self.merge_coalesce_fields)
3688 return []
3690 def merge_get_all_fields(self):
3691 """
3692 Return the list of *all* fields for the merge.
3694 This will call each of the following methods to collect all
3695 field names, then it returns the full *sorted* list.
3697 * :meth:`merge_get_additive_fields()`
3698 * :meth:`merge_get_coalesce_fields()`
3699 * :meth:`merge_get_simple_fields()`
3701 :returns: Sorted list of all field names.
3702 """
3703 fields = set()
3704 fields.update(self.merge_get_simple_fields())
3705 fields.update(self.merge_get_additive_fields())
3706 fields.update(self.merge_get_coalesce_fields())
3707 return sorted(fields)
3709 def merge_get_data(self, obj):
3710 """
3711 Return a data dict for the given object, which will be either
3712 the "removing" or "keeping" record for the merge.
3714 By default this calls :meth:`merge_get_all_fields()` and then
3715 for each field, calls ``getattr()`` on the object. Subclass
3716 can override as needed for custom logic.
3718 :param obj: Reference to model/record instance.
3720 :returns: Data dict with all field values.
3721 """
3722 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()}
3724 def merge_get_final_data(self, removing, keeping):
3725 """
3726 Return the "final" data dict for the merge.
3728 The result will be identical to the "keeping" record, for all
3729 "simple" fields. However the "additive" and "coalesce" fields
3730 are handled specially per their nature, in which case those
3731 final values may or may not match the "keeping" record.
3733 :param removing: Data dict for the "removing" record.
3735 :param keeping: Data dict for the "keeping" record.
3737 :returns: Data dict with all "final" field values.
3739 See also:
3741 * :meth:`merge()`
3742 * :meth:`merge_get_additive_fields()`
3743 * :meth:`merge_get_coalesce_fields()`
3744 """
3745 final = dict(keeping)
3747 for field in self.merge_get_additive_fields():
3748 if isinstance(keeping[field], list):
3749 final[field] = sorted(set(removing[field] + keeping[field]))
3750 else:
3751 final[field] = removing[field] + keeping[field]
3753 for field in self.merge_get_coalesce_fields():
3754 if removing[field] is not None and keeping[field] is None:
3755 final[field] = removing[field]
3756 elif removing[field] and not keeping[field]:
3757 final[field] = removing[field]
3759 return final
3761 def merge_validate_and_execute(self, removing, keeping):
3762 """
3763 Validate and execute a merge for the two given records. It is
3764 called from :meth:`merge()`.
3766 This calls :meth:`merge_why_not()` and if that does not yield
3767 a reason to prevent the merge, then calls
3768 :meth:`merge_execute()`.
3770 If there was a reason not to merge, or if an error occurs
3771 during merge execution, a flash warning/error message is set
3772 to notify the user what happened.
3774 :param removing: Reference to the "removing" model instance/record.
3776 :param keeping: Reference to the "keeping" model instance/record.
3778 :returns: Boolean indicating whether merge execution completed
3779 successfully.
3780 """
3781 session = self.Session()
3783 # validate the merge
3784 if reason := self.merge_why_not(removing, keeping):
3785 warning = HTML.tag(
3786 "p", class_="block", c="Merge cannot proceed:"
3787 ) + HTML.tag("p", class_="block", c=reason)
3788 self.request.session.flash(warning, "warning")
3789 return False
3791 # execute the merge
3792 removed_title = str(removing)
3793 try:
3794 self.merge_execute(removing, keeping)
3795 session.flush()
3796 except Exception as err: # pylint: disable=broad-exception-caught
3797 session.rollback()
3798 log.warning("merge failed", exc_info=True)
3799 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag(
3800 "p", class_="block", c=self.app.render_error(err)
3801 )
3802 self.request.session.flash(warning, "error")
3803 return False
3805 self.request.session.flash(f"{removed_title} has been merged into {keeping}")
3806 return True
3808 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument
3809 """
3810 Can return a "reason" why the two given records should not be merged.
3812 This returns ``None`` by default, indicating the merge is
3813 allowed. Subclass can override as needed for custom logic.
3815 See also :meth:`merge_validate_and_execute()`.
3817 :param removing: Reference to the "removing" model instance/record.
3819 :param keeping: Reference to the "keeping" model instance/record.
3821 :returns: Reason not to merge (as string), or ``None``.
3822 """
3823 return None
3825 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument
3826 """
3827 Execute the actual merge for the two given objects.
3829 Default logic simply deletes the "removing" record. Subclass
3830 can override as needed for custom logic.
3832 See also :meth:`merge_validate_and_execute()`.
3834 :param removing: Reference to the "removing" model instance/record.
3836 :param keeping: Reference to the "keeping" model instance/record.
3837 """
3838 session = self.Session()
3840 # nb. default "merge" does not update kept object!
3841 session.delete(removing)
3843 ##############################
3844 # row methods
3845 ##############################
3847 def get_rows_title(self):
3848 """
3849 Returns the display title for model **rows** grid, if
3850 applicable/desired. Only relevant if :attr:`has_rows` is
3851 true.
3853 This will first check :attr:`rows_title` and use that if set;
3854 otherwise it returns the value from
3855 :meth:`get_row_model_title_plural()`.
3857 :returns: String title for rows grid.
3858 """
3859 if hasattr(self, "rows_title"):
3860 return self.rows_title
3861 return self.get_row_model_title_plural()
3863 def get_row_parent(self, row):
3864 """
3865 This must return the parent object for the given child row.
3866 Only relevant if :attr:`has_rows` is true.
3868 Default logic is not implemented; subclass must override.
3869 """
3870 raise NotImplementedError
3872 def make_row_model_grid(self, obj, **kwargs):
3873 """
3874 Create and return a grid for an object's **rows** data, for
3875 use in :meth:`view()`. Only applicable if :attr:`has_rows` is
3876 true.
3878 This may call several other methods to collect the grid
3879 constructor kwargs etc.:
3881 * :meth:`get_row_grid_key()`
3882 * :meth:`get_row_grid_columns()`
3883 * :meth:`get_row_grid_data()`
3884 * :meth:`get_row_action_url_view()`
3885 * :meth:`get_row_action_url_edit()`
3886 * :meth:`get_row_action_url_delete()`
3887 * :meth:`~wuttaweb.views.base.View.make_grid()`
3888 * :meth:`configure_row_grid()`
3890 Before returning the grid this also calls its
3891 :meth:`~wuttaweb.grids.base.Grid.load_settings()` method.
3893 :param obj: Current model instance for which rows data is
3894 being displayed.
3896 :param \\**kwargs: Override and/or additional kwargs for the
3897 grid constructor.
3899 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
3900 rows data.
3901 """
3902 if "key" not in kwargs:
3903 kwargs["key"] = self.get_row_grid_key()
3905 if "model_class" not in kwargs:
3906 model_class = self.get_row_model_class()
3907 if model_class:
3908 kwargs["model_class"] = model_class
3910 if "columns" not in kwargs:
3911 kwargs["columns"] = self.get_row_grid_columns()
3913 if "data" not in kwargs:
3914 kwargs["data"] = self.get_row_grid_data(obj)
3916 kwargs.setdefault("filterable", self.rows_filterable)
3917 kwargs.setdefault("filter_defaults", self.rows_filter_defaults)
3918 kwargs.setdefault("sortable", self.rows_sortable)
3919 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend)
3920 kwargs.setdefault("sort_defaults", self.rows_sort_defaults)
3921 kwargs.setdefault("paginated", self.rows_paginated)
3922 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend)
3924 if "actions" not in kwargs:
3925 actions = []
3927 if self.rows_viewable and self.has_perm("view_row"):
3928 actions.append(
3929 self.make_grid_action(
3930 "view", icon="eye", url=self.get_row_action_url_view
3931 )
3932 )
3934 if self.rows_editable and self.has_perm("edit_row"):
3935 actions.append(
3936 self.make_grid_action(
3937 "edit", icon="edit", url=self.get_row_action_url_edit
3938 )
3939 )
3941 if self.rows_deletable and self.has_perm("delete_row"):
3942 actions.append(
3943 self.make_grid_action(
3944 "delete",
3945 icon="trash",
3946 url=self.get_row_action_url_delete,
3947 link_class="has-text-danger",
3948 )
3949 )
3951 if actions:
3952 kwargs["actions"] = actions
3954 grid = self.make_grid(**kwargs)
3955 self.configure_row_grid(grid)
3956 grid.load_settings()
3957 return grid
3959 def get_row_grid_key(self):
3960 """
3961 Returns the (presumably) unique key to be used for the
3962 **rows** grid in :meth:`view()`. Only relevant if
3963 :attr:`has_rows` is true.
3965 This is called from :meth:`make_row_model_grid()`; in the
3966 resulting grid, this becomes
3967 :attr:`~wuttaweb.grids.base.Grid.key`.
3969 Whereas you can define :attr:`grid_key` for the main grid, the
3970 row grid key is always generated dynamically. This
3971 incorporates the current record key (whose rows are in the
3972 grid) so that the rows grid for each record is unique.
3973 """
3974 parts = [self.get_grid_key()]
3975 for key in self.get_model_key():
3976 parts.append(str(self.request.matchdict[key]))
3977 return ".".join(parts)
3979 def get_row_grid_columns(self):
3980 """
3981 Returns the default list of column names for the **rows**
3982 grid, for use in :meth:`view()`. Only relevant if
3983 :attr:`has_rows` is true.
3985 This is called by :meth:`make_row_model_grid()`; in the
3986 resulting grid, this becomes
3987 :attr:`~wuttaweb.grids.base.Grid.columns`.
3989 This method may return ``None``, in which case the grid may
3990 (try to) generate its own default list.
3992 Subclass may define :attr:`row_grid_columns` for simple cases,
3993 or can override this method if needed.
3995 Also note that :meth:`configure_row_grid()` may be used to
3996 further modify the final column set, regardless of what this
3997 method returns. So a common pattern is to declare all
3998 "supported" columns by setting :attr:`row_grid_columns` but
3999 then optionally remove or replace some of those within
4000 :meth:`configure_row_grid()`.
4001 """
4002 if hasattr(self, "row_grid_columns"):
4003 return self.row_grid_columns
4004 return None
4006 def get_row_grid_data(self, obj):
4007 """
4008 Returns the data for the **rows** grid, for use in
4009 :meth:`view()`. Only relevant if :attr:`has_rows` is true.
4011 This is called by :meth:`make_row_model_grid()`; in the
4012 resulting grid, this becomes
4013 :attr:`~wuttaweb.grids.base.Grid.data`.
4015 Default logic not implemented; subclass must define this.
4016 """
4017 raise NotImplementedError
4019 def configure_row_grid(self, grid):
4020 """
4021 Configure the **rows** grid for use in :meth:`view()`. Only
4022 relevant if :attr:`has_rows` is true.
4024 This is called by :meth:`make_row_model_grid()`.
4026 The default logic here (currently) does 2 things; subclass can
4027 override as needed:
4029 * remove the ``uuid`` column if present
4030 * call :meth:`set_row_labels()`
4031 """
4032 # we should never need this column
4033 grid.remove("uuid")
4035 # collect, apply labels
4036 self.set_row_labels(grid)
4038 def allow_create_row(self, obj): # pylint: disable=unused-argument
4039 """
4040 This must return a boolean indicating whether the user is
4041 allowed to create a new child row for the parent object.
4043 This is called from :meth:`configure_row_grid()` - and
4044 possibly other places?
4046 Default logic checks :attr:`rows_creatable` and also ensures
4047 the user has relevant ``create_row`` permission.
4049 :param obj: Reference to the parent object. This will be an
4050 instance of :attr:`model_class`.
4052 :returns: Boolean, true if user can create new rows for the
4053 object.
4054 """
4055 if not self.rows_creatable:
4056 return False
4058 if not self.has_perm("create_row"):
4059 return False
4061 return True
4063 def set_row_labels(self, obj):
4064 """
4065 Set label overrides on a **row** form or grid, based on what
4066 is defined by the view class and its parent class(es).
4068 This is called automatically from
4069 :meth:`configure_row_grid()` and
4070 :meth:`configure_row_form()`.
4072 This calls :meth:`collect_row_labels()` to find everything,
4073 then it assigns the labels using one of (based on ``obj``
4074 type):
4076 * :func:`wuttaweb.forms.base.Form.set_label()`
4077 * :func:`wuttaweb.grids.base.Grid.set_label()`
4079 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
4080 :class:`~wuttaweb.forms.base.Form` instance.
4081 """
4082 labels = self.collect_row_labels()
4083 for key, label in labels.items():
4084 obj.set_label(key, label)
4086 def collect_row_labels(self):
4087 """
4088 Collect all **row** labels defined within the view class
4089 hierarchy.
4091 This is called by :meth:`set_row_labels()`.
4093 :returns: Dict of all labels found.
4094 """
4095 labels = {}
4096 hierarchy = self.get_class_hierarchy()
4097 for cls in hierarchy:
4098 if hasattr(cls, "row_labels"):
4099 labels.update(cls.row_labels)
4100 return labels
4102 def get_row_action_route_kwargs(self, row, parent=None):
4103 """
4104 Get a dict of route kwargs for the given **row** object.
4106 This is called from :meth:`get_row_action_url()` and must return
4107 kwargs suitable for use with ``request.route_url()``.
4109 By default the returned dict will include model keys for both
4110 the parent and row; field names for the latter being prefixed
4111 by ``row_``. See also :meth:`get_row_url_prefix()`.
4113 :param row: Row model instance.
4115 :param parent: Optional parent model instance. If not
4116 specified, will be fetched via :meth:`get_row_parent()`.
4118 :returns: Dict of route kwargs for the row.
4119 """
4120 obj = parent or self.get_row_parent(row)
4121 obj_kw = self.get_action_route_kwargs(obj)
4123 row_keys = self.get_row_model_key()
4124 try:
4125 row_kw = {f"row_{key}": row[key] for key in row_keys}
4126 except TypeError:
4127 row_kw = {f"row_{key}": getattr(row, key) for key in row_keys}
4129 row_kw.update(obj_kw)
4130 return row_kw
4132 def get_row_action_url(self, action, row, parent=None, **kwargs):
4133 """
4134 Retrieve the "view" action URL for the given row object. Only
4135 relevant if :attr:`rows_viewable` is true.
4137 Default logic always generates and returns the URL; subclass
4138 may override to make that conditional based on the row data
4139 etc.
4141 :param row: Row model instance.
4143 :param parent: Optional parent model instance. If not
4144 specified, will be fetched via :meth:`get_row_parent()`.
4146 :returns: View URL (as string) for the row, or ``None`` to
4147 omit the row's View action link.
4148 """
4149 kw = self.get_row_action_route_kwargs(row, parent=parent)
4150 kw.update(kwargs)
4151 route_prefix = self.get_route_prefix()
4152 return self.request.route_url(f"{route_prefix}.{action}", **kw)
4154 def get_row_action_url_view(self, row, i): # pylint: disable=unused-argument
4155 """
4156 Retrieve the "view" action URL for the given row object. Only
4157 relevant if :attr:`rows_viewable` is true.
4159 Default logic always generates and returns the URL; subclass
4160 may override to make that conditional based on the row data
4161 etc.
4163 :param row: Reference to a child row object.
4165 :param i: 1-based sequence number for the row within its
4166 (visible) grid.
4168 :returns: View URL (as string) for the row, or ``None`` to
4169 omit the row's View action link.
4170 """
4171 return self.get_row_action_url("view_row", row)
4173 def get_row_action_url_edit(self, row, i): # pylint: disable=unused-argument
4174 """
4175 Retrieve the "edit" action URL for the given row. Only
4176 relevant if :attr:`rows_editable` is true.
4178 Default logic always generates and returns the URL; subclass
4179 may override to make that conditional based on the row data
4180 etc.
4182 :param row: Row model instance.
4184 :param i: 1-based sequence number for the row within its
4185 (visible) grid.
4187 :returns: Edit URL (as string) for the row, or ``None`` to
4188 omit the row's Edit action link.
4189 """
4190 if self.is_row_editable(row):
4191 return self.get_row_action_url("edit_row", row)
4192 return None
4194 def get_row_action_url_delete(self, row, i): # pylint: disable=unused-argument
4195 """
4196 Retrieve the "delete" action URL for the given row. Only
4197 relevant if :attr:`rows_deletable` is true.
4199 Default logic always generates and returns the URL; subclass
4200 may override to make that conditional based on the row data
4201 etc.
4203 :param row: Row model instance.
4205 :param i: 1-based sequence number for the row within its
4206 (visible) grid.
4208 :returns: Delete URL (as string) for the row, or ``None`` to
4209 omit the row's Delete action link.
4210 """
4211 if self.is_row_deletable(row):
4212 return self.get_row_action_url("delete_row", row)
4213 return None
4215 def create_row(self):
4216 """
4217 View to create a new "child row" record.
4219 This usually corresponds to a URL like ``/widgets/XXX/new-row``.
4221 By default, this view is included only if
4222 :attr:`rows_creatable` is true.
4224 The default "create row" view logic will show a form with
4225 field widgets, allowing user to submit new values which are
4226 then persisted to the DB (assuming typical SQLAlchemy model).
4228 Subclass normally should not override this method, but rather
4229 one of the related methods which are called (in)directly by
4230 this one:
4232 * :meth:`make_row_model_form()`
4233 * :meth:`configure_row_form()`
4234 * :meth:`create_row_save_form()`
4235 * :meth:`redirect_after_create_row()`
4236 """
4237 self.creating = True
4238 parent = self.get_instance()
4239 parent_url = self.get_action_url("view", parent)
4241 form = self.make_row_model_form(cancel_url_fallback=parent_url)
4242 if form.validate():
4243 result = self.create_row_save_form(form)
4244 return self.redirect_after_create_row(result)
4246 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
4247 parent_link = tags.link_to(self.get_instance_title(parent), parent_url)
4248 index_title_rendered = HTML.literal("<span> »</span>").join(
4249 [index_link, parent_link]
4250 )
4252 context = {
4253 "form": form,
4254 "index_title_rendered": index_title_rendered,
4255 "row_model_title": self.get_row_model_title(),
4256 }
4257 return self.render_to_response("create_row", context)
4259 def create_row_save_form(self, form):
4260 """
4261 This method converts the validated form data to a row model
4262 instance, and then saves the result to DB. It is called by
4263 :meth:`create_row()`.
4265 :returns: The resulting row model instance, as produced by
4266 :meth:`objectify()`.
4267 """
4268 row = self.objectify(form)
4269 session = self.Session()
4270 session.add(row)
4271 session.flush()
4272 return row
4274 def redirect_after_create_row(self, row):
4275 """
4276 Returns a redirect to the "view parent" page relative to the
4277 given newly-created row. Subclass may override as needed.
4279 This is called by :meth:`create_row()`.
4280 """
4281 parent = self.get_row_parent(row)
4282 return self.redirect(self.get_action_url("view", parent))
4284 def make_row_model_form(self, model_instance=None, **kwargs):
4285 """
4286 Create and return a form for the row model.
4288 This is called by :meth:`create_row()` and :meth:`view_row()`.
4290 See also related methods, which are called by this one:
4292 * :meth:`get_row_model_class()`
4293 * :meth:`get_row_form_fields()`
4294 * :meth:`~wuttaweb.views.base.View.make_form()`
4295 * :meth:`configure_row_form()`
4297 :param model_instance: Despite the generic name, this must be
4298 the desired **row** model instance. Or can be ``None`` for
4299 a creation form.
4301 :returns: :class:`~wuttaweb.forms.base.Form` instance.
4302 """
4303 if "model_class" not in kwargs:
4304 model_class = self.get_row_model_class()
4305 if model_class:
4306 kwargs["model_class"] = model_class
4308 kwargs["model_instance"] = model_instance
4310 if not kwargs.get("fields"):
4311 fields = self.get_row_form_fields()
4312 if fields:
4313 kwargs["fields"] = fields
4315 form = self.make_form(**kwargs)
4316 self.configure_row_form(form)
4317 return form
4319 def get_row_form_fields(self):
4320 """
4321 Returns the initial list of field names for the row model
4322 form.
4324 This is called by :meth:`make_row_model_form()`; in the
4325 resulting :class:`~wuttaweb.forms.base.Form` instance, this
4326 becomes :attr:`~wuttaweb.forms.base.Form.fields`.
4328 This method may return ``None``, in which case the form may
4329 (try to) generate its own default list.
4331 Subclass may define :attr:`row_form_fields` for simple cases,
4332 or can override this method if needed.
4334 Note that :meth:`configure_row_form()` may be used to further
4335 modify the final field list, regardless of what this method
4336 returns. So a common pattern is to declare all "supported"
4337 fields by setting :attr:`row_form_fields` but then optionally
4338 remove or replace some in :meth:`configure_row_form()`.
4339 """
4340 if hasattr(self, "row_form_fields"):
4341 return self.row_form_fields
4342 return None
4344 def configure_row_form(self, form):
4345 """
4346 Configure the row model form.
4348 This is called by :meth:`make_row_model_form()` - for multiple
4349 CRUD views (create, view, edit, delete, possibly others).
4351 The ``form`` param will already be "complete" and ready to use
4352 as-is, but this method can further modify it based on request
4353 details etc.
4355 Subclass can override as needed, although be sure to invoke
4356 this parent method via ``super()`` if so.
4357 """
4358 form.remove("uuid")
4359 self.set_row_labels(form)
4361 def get_row_instance(self):
4362 """
4363 Retrieve the row model instance for current view route.
4365 This will typically query the underlying **rows** table using
4366 keys from request
4367 :attr:`~pyramid:pyramid.request.Request.matchdict`.
4369 This should be automatic for a SQLAlchemy mapped class. For
4370 other scenarios, subclass probably must override this method.
4372 :returns: Current row model instance.
4373 """
4374 if row_model_class := self.get_row_model_class():
4375 session = self.Session()
4376 matchdict = self.request.matchdict
4378 def filtr(query, row_model_key):
4379 key = matchdict[f"row_{row_model_key}"]
4380 return query.filter(getattr(row_model_class, row_model_key) == key)
4382 query = session.query(row_model_class)
4383 for key in self.get_row_model_key():
4384 query = filtr(query, key)
4386 try:
4387 return query.one()
4388 except orm.exc.NoResultFound:
4389 pass
4391 raise self.notfound()
4393 raise NotImplementedError(
4394 f"must define get_row_instance() method for view class: {self.__class__}"
4395 )
4397 def get_row_instance_title(self, row):
4398 """
4399 Retrieve the human-friendly display title for a row model
4400 instance.
4402 :param row: Row model instance.
4404 :returns: String title.
4405 """
4406 return str(row) or "(no title)"
4408 def view_row(self):
4409 """
4410 View to "view" a **row** model record.
4412 This usually corresponds to URL like ``/widgets/XXX/rows/YYY``
4414 By default, this route is included only if
4415 :attr:`rows_viewable` is true.
4417 The default logic here mostly just calls
4418 :meth:`get_row_instance()` and :meth:`make_row_model_form()`,
4419 then renders the ``/master/view_row.mako`` template.
4420 """
4421 self.viewing = True
4422 row = self.get_row_instance()
4423 obj = self.get_row_parent(row)
4425 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
4426 parent_title = self.get_instance_title(obj)
4427 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj))
4428 index_title_rendered = HTML.literal("<span> »</span>").join(
4429 [index_link, parent_link]
4430 )
4432 context = {
4433 "index_title_rendered": index_title_rendered,
4434 "parent_instance": obj,
4435 "parent_instance_title": parent_title,
4436 "instance": row,
4437 "form": self.make_row_model_form(row, readonly=True),
4438 }
4440 return self.render_to_response("view_row", context)
4442 def is_row_editable(self, row): # pylint: disable=unused-argument
4443 """
4444 Check if "edit" should be allowed for the given row. Only
4445 applicable if :attr:`rows_editable` is true.
4447 By default this always return ``True``; subclass can override
4448 to prevent edit for certain rows.
4450 :param row: Row model instance.
4452 :returns: Boolean, whether edit is allowed for the row.
4453 """
4454 return True
4456 def edit_row(self):
4457 """
4458 View to "edit" a **row** model record.
4460 This usually corresponds to URL like
4461 ``/widgets/XXX/rows/YYY/edit``
4463 By default, this route is included only if
4464 :attr:`rows_editable` is true.
4466 The default logic here mostly just calls
4467 :meth:`get_row_instance()` and :meth:`make_row_model_form()`,
4468 then renders the ``/master/edit_row.mako`` template.
4470 On successful update it calls :meth:`edit_row_save_form()` and
4471 then :meth:`redirect_after_edit_row()`.
4472 """
4473 self.editing = True
4474 row = self.get_row_instance()
4475 obj = self.get_row_parent(row)
4477 form = self.make_row_model_form(row)
4478 if form.validate():
4479 try:
4480 result = self.edit_row_save_form(form)
4481 except Exception as err: # pylint: disable=broad-exception-caught
4482 log.warning("failed to save 'edit_row' form", exc_info=True)
4483 self.request.session.flash(f"Edit failed: {err}", "error")
4484 else:
4485 return self.redirect_after_edit_row(result)
4487 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
4488 parent_title = self.get_instance_title(obj)
4489 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj))
4490 index_title_rendered = HTML.literal("<span> »</span>").join(
4491 [index_link, parent_link]
4492 )
4494 context = {
4495 "index_title_rendered": index_title_rendered,
4496 "parent_instance": obj,
4497 "parent_instance_title": parent_title,
4498 "instance": row,
4499 "form": form,
4500 }
4502 return self.render_to_response("edit_row", context)
4504 def edit_row_save_form(self, form):
4505 """
4506 Convert the form data to a row model instance, and save it to
4507 the database. This is called by :meth:`edit_row()` when user
4508 submits a valid edit form.
4510 :returns: Final row model instance, as produced by
4511 :meth:`objectify()`.
4512 """
4513 row = self.objectify(form)
4514 session = self.Session()
4515 session.add(row)
4516 session.flush()
4517 return row
4519 def redirect_after_edit_row(self, row):
4520 """
4521 Returns a redirect to the "view parent" page relative to the
4522 given row. Subclass may override as needed.
4524 This is called by :meth:`edit_row()` after user submits a
4525 valid edit form.
4526 """
4527 parent = self.get_row_parent(row)
4528 return self.redirect(self.get_action_url("view", parent))
4530 def is_row_deletable(self, row): # pylint: disable=unused-argument
4531 """
4532 Check if "delete" should be allowed for the given row. Only
4533 applicable if :attr:`rows_deletable` is true.
4535 By default this always return ``True``; subclass can override
4536 to prevent delete for certain rows.
4538 :param row: Row model instance.
4540 :returns: Boolean, whether delete is allowed for the row.
4541 """
4542 return True
4544 def delete_row(self):
4545 """
4546 View to delete a **row** model instance.
4548 This usually corresponds to URL like
4549 ``/widgets/XXX/rows/YYY/delete``
4551 By default, this route is included only if
4552 :attr:`rows_deletable` is true.
4554 The default logic calls :meth:`make_delete_row_form()` and
4555 shows that to the user. When they submit, it calls
4556 :meth:`save_delete_row_form()` and then redirects.
4558 Note that this gets the value from
4559 :meth:`redirect_after_delete_row()` *before* saving the delete
4560 form, since at that point we no longer would have a reliable
4561 row reference.
4562 """
4563 self.deleting = True
4564 row = self.get_row_instance()
4565 obj = self.get_row_parent(row)
4567 if not self.is_row_deletable(row):
4568 return self.redirect(self.get_row_action_url("view_row", row))
4570 form = self.make_delete_row_form(row)
4572 # nb. validate() often returns empty dict here
4573 if form.validate() is not False:
4575 # nb. must figure out redirect before we trash the row
4576 redirect = self.redirect_after_delete_row(row)
4578 try:
4579 self.save_delete_row_form(form)
4580 except Exception as err: # pylint: disable=broad-exception-caught
4581 log.warning("failed to save 'delete_row' form", exc_info=True)
4582 self.request.session.flash(f"Delete failed: {err}", "error")
4583 else:
4584 return redirect
4586 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
4587 parent_title = self.get_instance_title(obj)
4588 parent_link = tags.link_to(parent_title, self.get_action_url("view", obj))
4589 index_title_rendered = HTML.literal("<span> »</span>").join(
4590 [index_link, parent_link]
4591 )
4593 context = {
4594 "index_title_rendered": index_title_rendered,
4595 "parent_instance": obj,
4596 "parent_instance_title": parent_title,
4597 "instance": row,
4598 "form": form,
4599 }
4601 return self.render_to_response("delete_row", context)
4603 def make_delete_row_form(self, row):
4604 """
4605 Make the "delete" form for a row. This is called by
4606 :meth:`delete_row()`.
4608 Default logic calls :meth:`make_row_model_form()` but with a
4609 twist:
4611 The form proper is *not* readonly; this ensures the form has a
4612 submit button etc. But then all fields in the form are
4613 explicitly marked readonly.
4615 :returns: :class:`~wuttaweb.forms.base.Form` instance
4616 """
4617 # nb. this form proper is not readonly..
4618 form = self.make_row_model_form(
4619 row,
4620 cancel_url_fallback=self.get_row_action_url("view_row", row),
4621 button_label_submit="DELETE Forever",
4622 button_icon_submit="trash",
4623 button_type_submit="is-danger",
4624 )
4626 # ..but *all* fields are readonly
4627 form.readonly_fields = set(form.fields)
4628 return form
4630 def save_delete_row_form(self, form):
4631 """
4632 Save the "delete row" form. This is called by
4633 :meth:`delete_row()`.
4635 Default logic calls :meth:`delete_row_instance()`. Normally
4636 subclass would override *that* for non-standard use cases, but
4637 it could also/instead override *this* method.
4639 This method is not expected to return a value. If it does, it
4640 will be ignored.
4641 """
4642 row = form.model_instance
4643 self.delete_row_instance(row)
4645 def delete_row_instance(self, row):
4646 """
4647 Delete the given row. This is called by
4648 :meth:`save_delete_row_form()`.
4650 The default logic just calls
4651 :meth:`~sqlalchemy:sqlalchemy.orm.Session.delete()` for the
4652 row, on its session.
4654 Subclass may need to override in order to properly remove the
4655 association between row and its parent object etc.
4657 :param row: Row model instance.
4658 """
4659 session = self.app.get_session(row)
4660 session.delete(row)
4662 def redirect_after_delete_row(self, row):
4663 """
4664 Returns a redirect to the "view parent" page relative to the
4665 given row. Subclass may override as needed.
4667 This is called by :meth:`delete_row()` *before* the row is
4668 deleted.
4670 :param row: Row model instance.
4672 :returns: :class:`~pyramid.httpexceptions.HTTPFound` redirect
4673 instance.
4674 """
4675 parent = self.get_row_parent(row)
4676 return self.redirect(self.get_action_url("view", parent))
4678 ##############################
4679 # import/export methods
4680 ##############################
4682 def collect_importer_params(self):
4683 """
4684 Collect import/export params from the request ``POST`` data,
4685 coercing values as needed.
4687 This is called from :meth:`do_import_full()` but can be used
4688 for custom "single" import/export also.
4690 The params from this are intended for use with the import
4691 handler
4692 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()`
4693 method. As such, they are a "mixture" of params, some of
4694 which are for the :term:`import handler` and some for the
4695 :term:`importer`. The following should always be included:
4697 * ``create`` - bool; corresponds to
4698 :attr:`~wuttasync:wuttasync.importing.base.Importer.create`
4699 * ``update`` - bool; corresponds to
4700 :attr:`~wuttasync:wuttasync.importing.base.Importer.update`
4701 * ``delete`` - bool; corresponds to
4702 :attr:`~wuttasync:wuttasync.importing.base.Importer.delete`
4703 * ``dry_run`` - bool; corresponds to
4704 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.dry_run`
4705 * ``warnings`` - bool; corresponds to
4706 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.warnings`
4708 However by default *all* params from ``POST`` are included;
4709 the above are just "guaranteed" to be present. As of now
4710 the only other param typically expected is:
4712 * ``warnings_recipients`` - string or null; corresponds to
4713 :attr:`~wuttasync:wuttasync.importing.handlers.ImportHandler.warnings_recipients`
4715 :returns: Dict of import/export params.
4716 """
4717 params = dict(self.request.POST)
4719 for flag in ("create", "update", "delete", "dry_run", "warnings"):
4720 params[flag] = self.config.parse_bool(params.get(flag, False))
4722 return params
4724 def do_import_full(self, handler, models=None, **kwargs):
4725 """
4726 This is a helper method to orchestrate a typical "full"
4727 import/export run. See :meth:`add_importer_views()` for a
4728 simple example.
4730 In the typical use case, caller need only provide the handler,
4731 and the value from :meth:`get_model_name()` will be the only
4732 thing we ask the handler to process. But you can specify
4733 explicit ``models`` if needed.
4735 This calls :meth:`collect_importer_params()` and feeds the
4736 result to
4737 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()`
4738 along with the desired model name(s).
4740 If an error occurs, appropriate flash warning is set.
4741 Otherwise :meth:`flash_import_summary()` is called to set a
4742 more "complete" message.
4744 :param handler: Instance of
4745 :class:`~wuttasync:wuttasync.importing.handlers.ImportHandler`.
4747 :param models: Optional model name(s) to import/export. If
4748 not specified, the view model is assumed.
4750 :param \\**kwargs: Remaining kwargs are added to the other
4751 params, and passed along to ``process_data()``.
4752 """
4753 # nb. we must "allow" GET method in general, but of course we
4754 # don't want to process data for simple GET. if we did not
4755 # allow GET in the routing though, the URL may be
4756 # interpreted as a record UUID and that causes errors
4757 # TODO: maybe we should raise an explicit error here?
4758 if self.request.method != "POST":
4759 return
4761 if not models:
4762 models = [self.get_model_name()]
4763 if isinstance(models, str):
4764 models = self.config.parse_list(models)
4766 try:
4767 params = self.collect_importer_params()
4768 params.update(kwargs)
4769 changes = handler.process_data(*models, **params)
4770 self.flash_import_summary(handler, changes)
4772 except Exception as err: # pylint: disable=broad-exception-caught
4773 log.warning(
4774 "process failed: %s (%s) for models: %s",
4775 handler.get_title(),
4776 handler.actioning,
4777 models,
4778 exc_info=True,
4779 )
4780 self.request.session.flash(
4781 f"Process failed: {self.app.render_error(err)}", "error"
4782 )
4784 def flash_import_summary(self, handler, changes):
4785 """
4786 Set a "detailed" flash message to summarize the result of an
4787 import/export run.
4789 The key data points included in this are:
4791 * source, target (e.g. Foo → Poser)
4792 * orientation (import vs. export)
4793 * dry-run flag
4794 * change highlights
4796 This is called from :meth:`do_import_full()` but can be used
4797 for custom "single" import/export also.
4799 :param handler:
4800 :class:`~wuttasync:wuttasync.importing.handlers.ImportHandler`
4801 instance.
4803 :param changes: Data changes from the import/export run, as
4804 returned by
4805 :meth:`~wuttasync:wuttasync.importing.handlers.ImportHandler.process_data()`.
4806 """
4807 msg = [
4808 HTML.tag("span", c="Process complete for "),
4809 HTML.tag(
4810 "span",
4811 c=f"{handler.get_title()} ({handler.actioning})",
4812 class_="has-text-weight-bold",
4813 ),
4814 ]
4815 if handler.dry_run:
4816 msg.append(HTML.tag("span", c=" - DRY RUN", class_="is-italic"))
4818 models = []
4819 for model_name, (created, updated, deleted) in changes.items():
4820 name = HTML.tag("span", class_="has-text-weight-bold", c=model_name)
4821 counts = HTML.tag(
4822 "span",
4823 c="; ".join(
4824 [
4825 f"{self.app.render_quantity(len(created))} created",
4826 f"{self.app.render_quantity(len(updated))} updated",
4827 f"{self.app.render_quantity(len(deleted))} deleted",
4828 ]
4829 ),
4830 )
4831 models.append(HTML.tag("li", c=[name, HTML.literal(" - "), counts]))
4833 if models:
4834 summary = HTML.tag("ul", c=models)
4835 else:
4836 summary = HTML.tag("p", c="NO CHANGES", class_="is-italic")
4838 self.request.session.flash(HTML.tag("p", c=msg) + HTML.tag("br") + summary)
4840 ##############################
4841 # class methods
4842 ##############################
4844 @classmethod
4845 def get_model_class(cls):
4846 """
4847 Returns the model class for the view (if defined).
4849 A model class will *usually* be a SQLAlchemy mapped class,
4850 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
4852 There is no default value here, but a subclass may override by
4853 assigning :attr:`model_class`.
4855 Note that the model class is not *required* - however if you
4856 do not set the :attr:`model_class`, then you *must* set the
4857 :attr:`model_name`.
4858 """
4859 return cls.model_class
4861 @classmethod
4862 def get_model_name(cls):
4863 """
4864 Returns the model name for the view.
4866 A model name should generally be in the format of a Python
4867 class name, e.g. ``'WuttaWidget'``. (Note this is
4868 *singular*, not plural.)
4870 The default logic will call :meth:`get_model_class()` and
4871 return that class name as-is. A subclass may override by
4872 assigning :attr:`model_name`.
4873 """
4874 if hasattr(cls, "model_name"):
4875 return cls.model_name
4877 return cls.get_model_class().__name__
4879 @classmethod
4880 def get_model_name_normalized(cls):
4881 """
4882 Returns the "normalized" model name for the view.
4884 A normalized model name should generally be in the format of a
4885 Python variable name, e.g. ``'wutta_widget'``. (Note this is
4886 *singular*, not plural.)
4888 The default logic will call :meth:`get_model_name()` and
4889 simply lower-case the result. A subclass may override by
4890 assigning :attr:`model_name_normalized`.
4891 """
4892 if hasattr(cls, "model_name_normalized"):
4893 return cls.model_name_normalized
4895 return cls.get_model_name().lower()
4897 @classmethod
4898 def get_model_title(cls):
4899 """
4900 Returns the "humanized" (singular) model title for the view.
4902 The model title will be displayed to the user, so should have
4903 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
4904 (Note this is *singular*, not plural.)
4906 The default logic will call :meth:`get_model_name()` and use
4907 the result as-is. A subclass may override by assigning
4908 :attr:`model_title`.
4909 """
4910 if hasattr(cls, "model_title"):
4911 return cls.model_title
4913 if model_class := cls.get_model_class():
4914 if hasattr(model_class, "__wutta_hint__"):
4915 if model_title := model_class.__wutta_hint__.get("model_title"):
4916 return model_title
4918 return cls.get_model_name()
4920 @classmethod
4921 def get_model_title_plural(cls):
4922 """
4923 Returns the "humanized" (plural) model title for the view.
4925 The model title will be displayed to the user, so should have
4926 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
4927 (Note this is *plural*, not singular.)
4929 The default logic will call :meth:`get_model_title()` and
4930 simply add a ``'s'`` to the end. A subclass may override by
4931 assigning :attr:`model_title_plural`.
4932 """
4933 if hasattr(cls, "model_title_plural"):
4934 return cls.model_title_plural
4936 if model_class := cls.get_model_class():
4937 if hasattr(model_class, "__wutta_hint__"):
4938 if model_title_plural := model_class.__wutta_hint__.get(
4939 "model_title_plural"
4940 ):
4941 return model_title_plural
4943 model_title = cls.get_model_title()
4944 return f"{model_title}s"
4946 @classmethod
4947 def get_model_key(cls):
4948 """
4949 Returns the "model key" for the master view.
4951 This should return a tuple containing one or more "field
4952 names" corresponding to the primary key for data records.
4954 In the most simple/common scenario, where the master view
4955 represents a Wutta-based SQLAlchemy model, the return value
4956 for this method is: ``('uuid',)``
4958 Any class mapped via SQLAlchemy should be supported
4959 automatically, the keys are determined from class inspection.
4961 But there is no "sane" default for other scenarios, in which
4962 case subclass should define :attr:`model_key`. If the model
4963 key cannot be determined, raises ``AttributeError``.
4965 :returns: Tuple of field names comprising the model key.
4966 """
4967 if hasattr(cls, "model_key"):
4968 keys = cls.model_key
4969 if isinstance(keys, str):
4970 keys = [keys]
4971 return tuple(keys)
4973 model_class = cls.get_model_class()
4974 if model_class:
4975 # nb. we want the primary key but must avoid column names
4976 # in case mapped class uses different prop keys
4977 inspector = sa.inspect(model_class)
4978 keys = [col.name for col in inspector.primary_key]
4979 return tuple(
4980 prop.key
4981 for prop in inspector.column_attrs
4982 if all(col.name in keys for col in prop.columns)
4983 )
4985 raise AttributeError(f"you must define model_key for view class: {cls}")
4987 @classmethod
4988 def get_route_prefix(cls):
4989 """
4990 Returns the "route prefix" for the master view. This prefix
4991 is used for all named routes defined by the view class.
4993 For instance if route prefix is ``'widgets'`` then a view
4994 might have these routes:
4996 * ``'widgets'``
4997 * ``'widgets.create'``
4998 * ``'widgets.edit'``
4999 * ``'widgets.delete'``
5001 The default logic will call
5002 :meth:`get_model_name_normalized()` and simply add an ``'s'``
5003 to the end, making it plural. A subclass may override by
5004 assigning :attr:`route_prefix`.
5005 """
5006 if hasattr(cls, "route_prefix"):
5007 return cls.route_prefix
5009 model_name = cls.get_model_name_normalized()
5010 return f"{model_name}s"
5012 @classmethod
5013 def get_permission_prefix(cls):
5014 """
5015 Returns the "permission prefix" for the master view. This
5016 prefix is used for all permissions defined by the view class.
5018 For instance if permission prefix is ``'widgets'`` then a view
5019 might have these permissions:
5021 * ``'widgets.list'``
5022 * ``'widgets.create'``
5023 * ``'widgets.edit'``
5024 * ``'widgets.delete'``
5026 The default logic will call :meth:`get_route_prefix()` and use
5027 that value as-is. A subclass may override by assigning
5028 :attr:`permission_prefix`.
5029 """
5030 if hasattr(cls, "permission_prefix"):
5031 return cls.permission_prefix
5033 return cls.get_route_prefix()
5035 @classmethod
5036 def get_url_prefix(cls):
5037 """
5038 Returns the "URL prefix" for the master view. This prefix is
5039 used for all URLs defined by the view class.
5041 Using the same example as in :meth:`get_route_prefix()`, the
5042 URL prefix would be ``'/widgets'`` and the view would have
5043 defined routes for these URLs:
5045 * ``/widgets/``
5046 * ``/widgets/new``
5047 * ``/widgets/XXX/edit``
5048 * ``/widgets/XXX/delete``
5050 The default logic will call :meth:`get_route_prefix()` and
5051 simply add a ``'/'`` to the beginning. A subclass may
5052 override by assigning :attr:`url_prefix`.
5053 """
5054 if hasattr(cls, "url_prefix"):
5055 return cls.url_prefix
5057 route_prefix = cls.get_route_prefix()
5058 return f"/{route_prefix}"
5060 @classmethod
5061 def get_instance_url_prefix(cls):
5062 """
5063 Generate the URL prefix specific to an instance for this model
5064 view. This will include model key param placeholders; it
5065 winds up looking like:
5067 * ``/widgets/{uuid}``
5068 * ``/resources/{foo}|{bar}|{baz}``
5070 The former being the most simple/common, and the latter
5071 showing what a "composite" model key looks like, with pipe
5072 symbols separating the key parts.
5073 """
5074 prefix = cls.get_url_prefix() + "/"
5075 for i, key in enumerate(cls.get_model_key()):
5076 if i:
5077 prefix += "|"
5078 prefix += f"{{{key}}}"
5079 return prefix
5081 @classmethod
5082 def get_template_prefix(cls):
5083 """
5084 Returns the "template prefix" for the master view. This
5085 prefix is used to guess which template path to render for a
5086 given view.
5088 Using the same example as in :meth:`get_url_prefix()`, the
5089 template prefix would also be ``'/widgets'`` and the templates
5090 assumed for those routes would be:
5092 * ``/widgets/index.mako``
5093 * ``/widgets/create.mako``
5094 * ``/widgets/edit.mako``
5095 * ``/widgets/delete.mako``
5097 The default logic will call :meth:`get_url_prefix()` and
5098 return that value as-is. A subclass may override by assigning
5099 :attr:`template_prefix`.
5100 """
5101 if hasattr(cls, "template_prefix"):
5102 return cls.template_prefix
5104 return cls.get_url_prefix()
5106 @classmethod
5107 def get_grid_key(cls):
5108 """
5109 Returns the (presumably) unique key to be used for the primary
5110 grid in the :meth:`index()` view. This key may also be used
5111 as the basis (key prefix) for secondary grids.
5113 This is called from :meth:`make_model_grid()`; in the
5114 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
5115 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
5117 The default logic for this method will call
5118 :meth:`get_route_prefix()` and return that value as-is. A
5119 subclass may override by assigning :attr:`grid_key`.
5120 """
5121 if hasattr(cls, "grid_key"):
5122 return cls.grid_key
5124 return cls.get_route_prefix()
5126 @classmethod
5127 def get_config_title(cls):
5128 """
5129 Returns the "config title" for the view/model.
5131 The config title is used for page title in the
5132 :meth:`configure()` view, as well as links to it. It is
5133 usually plural, e.g. ``"Wutta Widgets"`` in which case that
5134 winds up being displayed in the web app as: **Configure Wutta
5135 Widgets**
5137 The default logic will call :meth:`get_model_title_plural()`
5138 and return that as-is. A subclass may override by assigning
5139 :attr:`config_title`.
5140 """
5141 if hasattr(cls, "config_title"):
5142 return cls.config_title
5144 return cls.get_model_title_plural()
5146 @classmethod
5147 def get_row_model_class(cls):
5148 """
5149 Returns the "child row" model class for the view. Only
5150 relevant if :attr:`has_rows` is true.
5152 Default logic returns the :attr:`row_model_class` reference.
5154 :returns: Mapped class, or ``None``
5155 """
5156 return cls.row_model_class
5158 @classmethod
5159 def get_row_model_key(cls):
5160 """
5161 Returns the "row model key" for the view. Contrast with
5162 :meth:`get_model_key()` which is for the main (parent) model.
5164 This should return a tuple containing one or more "field
5165 names" corresponding to the primary key for **row** data
5166 records.
5168 In the most simple/common scenario, where the
5169 :attr:`row_model_class` represents a Wutta-based SQLAlchemy
5170 model, the return value is: ``("uuid",)``
5172 Any class mapped via SQLAlchemy should be supported
5173 automatically; the keys are determined from class inspection.
5175 But there is no "sane" default for other scenarios, in which
5176 case subclass should define :attr:`row_model_key`. If the row
5177 model key cannot be determined, raises ``AttributeError``.
5179 :returns: Tuple of field names comprising the row model key.
5180 """
5181 if hasattr(cls, "row_model_key"):
5182 keys = cls.row_model_key
5183 if isinstance(keys, str):
5184 keys = [keys]
5185 return tuple(keys)
5187 row_class = cls.get_row_model_class()
5188 if row_class:
5189 # nb. we want the primary key but must avoid column names
5190 # in case mapped class uses different prop keys
5191 inspector = sa.inspect(row_class)
5192 keys = [col.name for col in inspector.primary_key]
5193 return tuple(
5194 prop.key
5195 for prop in inspector.column_attrs
5196 if all(col.name in keys for col in prop.columns)
5197 )
5199 raise AttributeError(f"you must define row_model_key for view class: {cls}")
5201 @classmethod
5202 def get_row_model_name(cls):
5203 """
5204 Returns the row model name for the view.
5206 A model name should generally be in the format of a Python
5207 class name, e.g. ``'BatchRow'``. (Note this is *singular*,
5208 not plural.)
5210 The default logic will call :meth:`get_row_model_class()` and
5211 return that class name as-is. Subclass may override by
5212 assigning :attr:`row_model_name`.
5213 """
5214 if hasattr(cls, "row_model_name"):
5215 return cls.row_model_name
5217 return cls.get_row_model_class().__name__
5219 @classmethod
5220 def get_row_model_title(cls):
5221 """
5222 Returns the "humanized" (singular) title for the row model.
5224 The model title will be displayed to the user, so should have
5225 proper grammar and capitalization, e.g. ``"Batch Row"``.
5226 (Note this is *singular*, not plural.)
5228 The default logic will call :meth:`get_row_model_name()` and
5229 use the result as-is. Subclass may override by assigning
5230 :attr:`row_model_title`.
5232 See also :meth:`get_row_model_title_plural()`.
5233 """
5234 if hasattr(cls, "row_model_title"):
5235 return cls.row_model_title
5237 if model_class := cls.get_row_model_class():
5238 if hasattr(model_class, "__wutta_hint__"):
5239 if model_title := model_class.__wutta_hint__.get("model_title"):
5240 return model_title
5242 return cls.get_row_model_name()
5244 @classmethod
5245 def get_row_model_title_plural(cls):
5246 """
5247 Returns the "humanized" (plural) title for the row model.
5249 The model title will be displayed to the user, so should have
5250 proper grammar and capitalization, e.g. ``"Batch Rows"``.
5251 (Note this is *plural*, not singular.)
5253 The default logic will call :meth:`get_row_model_title()` and
5254 simply add a ``'s'`` to the end. Subclass may override by
5255 assigning :attr:`row_model_title_plural`.
5256 """
5257 if hasattr(cls, "row_model_title_plural"):
5258 return cls.row_model_title_plural
5260 if model_class := cls.get_row_model_class():
5261 if hasattr(model_class, "__wutta_hint__"):
5262 if model_title_plural := model_class.__wutta_hint__.get(
5263 "model_title_plural"
5264 ):
5265 return model_title_plural
5267 row_model_title = cls.get_row_model_title()
5268 return f"{row_model_title}s"
5270 @classmethod
5271 def get_row_url_prefix(cls):
5272 """
5273 Generate the URL prefix specific to a **row** model instance.
5275 For practical reasons, this URL will embed the key params for
5276 both the *parent* model as well as the row. Also, the latter
5277 will be prefixed with ``row_``, for example:
5279 * ``/widgets/{uuid}/rows/{row_uuid}``
5280 * ``/widgets/{id}|{name}/rows/{row_id}|{row_name}``
5282 The first example being the most simple/common, and the latter
5283 showing what a "composite" model key looks like (for both
5284 parent and row), with pipe symbols separating the key parts.
5285 """
5286 prefix = cls.get_instance_url_prefix() + "/rows/"
5287 for i, key in enumerate(cls.get_row_model_key()):
5288 if i:
5289 prefix += "|"
5290 prefix += f"{{row_{key}}}"
5291 return prefix
5293 ##############################
5294 # configuration
5295 ##############################
5297 @classmethod
5298 def defaults(cls, config):
5299 """
5300 Provide default Pyramid configuration for a master view.
5302 This is generally called from within the module's
5303 ``includeme()`` function, e.g.::
5305 from wuttaweb.views import MasterView
5307 class WidgetView(MasterView):
5308 model_name = 'Widget'
5310 def includeme(config):
5311 WidgetView.defaults(config)
5313 :param config: Reference to the app's
5314 :class:`pyramid:pyramid.config.Configurator` instance.
5315 """
5316 cls._defaults(config)
5318 @classmethod
5319 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches
5320 wutta_config = config.registry.settings.get("wutta_config")
5321 app = wutta_config.get_app()
5323 route_prefix = cls.get_route_prefix()
5324 permission_prefix = cls.get_permission_prefix()
5325 url_prefix = cls.get_url_prefix()
5326 model_title = cls.get_model_title()
5327 model_title_plural = cls.get_model_title_plural()
5329 # add to master view registry
5330 config.add_wutta_master_view(cls)
5332 # permission group
5333 config.add_wutta_permission_group(
5334 permission_prefix, model_title_plural, overwrite=False
5335 )
5337 # index
5338 if cls.listable:
5339 config.add_route(route_prefix, f"{url_prefix}/")
5340 config.add_view(
5341 cls,
5342 attr="index",
5343 route_name=route_prefix,
5344 permission=f"{permission_prefix}.list",
5345 )
5346 config.add_wutta_permission(
5347 permission_prefix,
5348 f"{permission_prefix}.list",
5349 f"Browse / search {model_title_plural}",
5350 )
5352 # grid totals
5353 if cls.has_grid_totals:
5354 config.add_route(
5355 f"{route_prefix}.fetch_grid_totals",
5356 f"{url_prefix}/fetch-grid-totals",
5357 request_method="GET",
5358 )
5359 config.add_view(
5360 cls,
5361 attr="fetch_grid_totals",
5362 route_name=f"{route_prefix}.fetch_grid_totals",
5363 permission=f"{permission_prefix}.list",
5364 renderer="json",
5365 )
5367 # create
5368 if cls.creatable:
5369 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new")
5370 config.add_view(
5371 cls,
5372 attr="create",
5373 route_name=f"{route_prefix}.create",
5374 permission=f"{permission_prefix}.create",
5375 )
5376 config.add_wutta_permission(
5377 permission_prefix,
5378 f"{permission_prefix}.create",
5379 f"Create new {model_title}",
5380 )
5382 # edit
5383 if cls.editable:
5384 instance_url_prefix = cls.get_instance_url_prefix()
5385 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit")
5386 config.add_view(
5387 cls,
5388 attr="edit",
5389 route_name=f"{route_prefix}.edit",
5390 permission=f"{permission_prefix}.edit",
5391 )
5392 config.add_wutta_permission(
5393 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}"
5394 )
5396 # delete
5397 if cls.deletable:
5398 instance_url_prefix = cls.get_instance_url_prefix()
5399 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete")
5400 config.add_view(
5401 cls,
5402 attr="delete",
5403 route_name=f"{route_prefix}.delete",
5404 permission=f"{permission_prefix}.delete",
5405 )
5406 config.add_wutta_permission(
5407 permission_prefix,
5408 f"{permission_prefix}.delete",
5409 f"Delete {model_title}",
5410 )
5412 # bulk delete
5413 if cls.deletable_bulk:
5414 config.add_route(
5415 f"{route_prefix}.delete_bulk",
5416 f"{url_prefix}/delete-bulk",
5417 request_method="POST",
5418 )
5419 config.add_view(
5420 cls,
5421 attr="delete_bulk",
5422 route_name=f"{route_prefix}.delete_bulk",
5423 permission=f"{permission_prefix}.delete_bulk",
5424 )
5425 config.add_wutta_permission(
5426 permission_prefix,
5427 f"{permission_prefix}.delete_bulk",
5428 f"Delete {model_title_plural} in bulk",
5429 )
5431 # merge
5432 if cls.mergeable:
5433 config.add_wutta_permission(
5434 permission_prefix,
5435 f"{permission_prefix}.merge",
5436 f"Merge 2 {model_title_plural}",
5437 )
5438 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge")
5439 config.add_view(
5440 cls,
5441 attr="merge",
5442 route_name=f"{route_prefix}.merge",
5443 permission=f"{permission_prefix}.merge",
5444 )
5446 # autocomplete
5447 if cls.has_autocomplete:
5448 config.add_route(
5449 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete"
5450 )
5451 config.add_view(
5452 cls,
5453 attr="autocomplete",
5454 route_name=f"{route_prefix}.autocomplete",
5455 renderer="json",
5456 permission=f"{route_prefix}.list",
5457 )
5459 # download
5460 if cls.downloadable:
5461 instance_url_prefix = cls.get_instance_url_prefix()
5462 config.add_route(
5463 f"{route_prefix}.download", f"{instance_url_prefix}/download"
5464 )
5465 config.add_view(
5466 cls,
5467 attr="download",
5468 route_name=f"{route_prefix}.download",
5469 permission=f"{permission_prefix}.download",
5470 )
5471 config.add_wutta_permission(
5472 permission_prefix,
5473 f"{permission_prefix}.download",
5474 f"Download file(s) for {model_title}",
5475 )
5477 # execute
5478 if cls.executable:
5479 instance_url_prefix = cls.get_instance_url_prefix()
5480 config.add_route(
5481 f"{route_prefix}.execute",
5482 f"{instance_url_prefix}/execute",
5483 request_method="POST",
5484 )
5485 config.add_view(
5486 cls,
5487 attr="execute",
5488 route_name=f"{route_prefix}.execute",
5489 permission=f"{permission_prefix}.execute",
5490 )
5491 config.add_wutta_permission(
5492 permission_prefix,
5493 f"{permission_prefix}.execute",
5494 f"Execute {model_title}",
5495 )
5497 # configure
5498 if cls.configurable:
5499 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure")
5500 config.add_view(
5501 cls,
5502 attr="configure",
5503 route_name=f"{route_prefix}.configure",
5504 permission=f"{permission_prefix}.configure",
5505 )
5506 config.add_wutta_permission(
5507 permission_prefix,
5508 f"{permission_prefix}.configure",
5509 f"Configure {model_title_plural}",
5510 )
5512 # view
5513 # nb. always register this one last, so it does not take
5514 # priority over model-wide action routes, e.g. delete_bulk
5515 if cls.viewable:
5516 instance_url_prefix = cls.get_instance_url_prefix()
5517 config.add_route(f"{route_prefix}.view", instance_url_prefix)
5518 config.add_view(
5519 cls,
5520 attr="view",
5521 route_name=f"{route_prefix}.view",
5522 permission=f"{permission_prefix}.view",
5523 )
5524 config.add_wutta_permission(
5525 permission_prefix, f"{permission_prefix}.view", f"View {model_title}"
5526 )
5528 # version history
5529 if cls.is_versioned() and app.continuum_is_enabled():
5530 instance_url_prefix = cls.get_instance_url_prefix()
5531 config.add_wutta_permission(
5532 permission_prefix,
5533 f"{permission_prefix}.versions",
5534 f"View change history for {model_title}",
5535 )
5536 config.add_route(
5537 f"{route_prefix}.versions", f"{instance_url_prefix}/history/"
5538 )
5539 config.add_view(
5540 cls,
5541 attr="view_versions",
5542 route_name=f"{route_prefix}.versions",
5543 permission=f"{permission_prefix}.versions",
5544 )
5545 config.add_route(
5546 f"{route_prefix}.version", f"{instance_url_prefix}/history/{{txnid}}"
5547 )
5548 config.add_view(
5549 cls,
5550 attr="view_version",
5551 route_name=f"{route_prefix}.version",
5552 permission=f"{permission_prefix}.versions",
5553 )
5555 ##############################
5556 # row-specific routes
5557 ##############################
5559 if cls.has_rows:
5560 row_model_title = cls.get_row_model_title()
5562 # create row
5563 if cls.rows_creatable:
5564 config.add_wutta_permission(
5565 permission_prefix,
5566 f"{permission_prefix}.create_row",
5567 f"Create new {row_model_title} for {model_title}",
5568 )
5569 config.add_route(
5570 f"{route_prefix}.create_row", f"{instance_url_prefix}/rows/new"
5571 )
5572 config.add_view(
5573 cls,
5574 attr="create_row",
5575 route_name=f"{route_prefix}.create_row",
5576 permission=f"{permission_prefix}.create_row",
5577 )
5579 # view row
5580 if cls.rows_viewable:
5581 row_url_prefix = cls.get_row_url_prefix()
5582 config.add_wutta_permission(
5583 permission_prefix,
5584 f"{permission_prefix}.view_row",
5585 f"View {row_model_title} for {model_title}",
5586 )
5587 config.add_route(f"{route_prefix}.view_row", row_url_prefix)
5588 config.add_view(
5589 cls,
5590 attr="view_row",
5591 route_name=f"{route_prefix}.view_row",
5592 permission=f"{permission_prefix}.view_row",
5593 )
5595 # edit row
5596 if cls.rows_editable:
5597 row_url_prefix = cls.get_row_url_prefix()
5598 config.add_wutta_permission(
5599 permission_prefix,
5600 f"{permission_prefix}.edit_row",
5601 f"Edit {row_model_title} for {model_title}",
5602 )
5603 config.add_route(f"{route_prefix}.edit_row", f"{row_url_prefix}/edit")
5604 config.add_view(
5605 cls,
5606 attr="edit_row",
5607 route_name=f"{route_prefix}.edit_row",
5608 permission=f"{permission_prefix}.edit_row",
5609 )
5611 # delete row
5612 if cls.rows_deletable:
5613 row_url_prefix = cls.get_row_url_prefix()
5614 config.add_wutta_permission(
5615 permission_prefix,
5616 f"{permission_prefix}.delete_row",
5617 f"Delete {row_model_title} from {model_title}",
5618 )
5619 config.add_route(
5620 f"{route_prefix}.delete_row", f"{row_url_prefix}/delete"
5621 )
5622 config.add_view(
5623 cls,
5624 attr="delete_row",
5625 route_name=f"{route_prefix}.delete_row",
5626 permission=f"{permission_prefix}.delete_row",
5627 )
5629 @classmethod
5630 def add_importer_views(cls, config, action, handler=None, permission_title=None):
5631 """
5632 Add route/view config for a given "action" identifier. Both
5633 "full" and "single" flavors are supported.
5635 The ``action`` is a string, usually like ``import_foo`` or
5636 ``export_foo``, which is used to generate the route names etc.
5638 The class must define corresponding view callable method(s)
5639 separately; this only does up-front config in Pyramid. For
5640 instance::
5642 from wuttaweb.views import MasterView
5644 class WidgetView(MasterView):
5646 # nb. maps to e.g. /widgets/import-foo
5647 def import_foo_full(self):
5648 # nb. import all widgets from foo
5649 handler = self.app.get_import_handler("import.to_poser.from_foo")
5650 self.do_import_full(handler)
5651 return self.redirect(self.get_index_url())
5653 # nb. maps to e.g. /widgets/XXX/import-foo
5654 def import_foo_single(self):
5655 # TODO: single-record import requires custom logic
5656 raise NotImplementedError
5658 @classmethod
5659 def defaults(cls, config):
5660 cls._defaults(config)
5661 cls.add_importer_views(config, "import_foo",
5662 permission_title="Import Widgets from Foo")
5664 See also the :meth:`do_import_full()` method.
5666 :param config: Reference to the app's
5667 :class:`pyramid:pyramid.config.Configurator` instance.
5669 :param action: Name to use as basis for permission, route,
5670 view callable method names.
5672 :param handler: If you provide an import handler instance, it
5673 can be used to generate the permission display title.
5675 :param permission_title: Override for the permission display
5676 title.
5678 Note that you "must" provide either ``handler`` or
5679 ``permission_title``, otherwise the default generated
5680 permission title will be pretty bad.
5681 """
5682 route_prefix = cls.get_route_prefix()
5683 permission_prefix = cls.get_permission_prefix()
5684 url_prefix = cls.get_url_prefix()
5685 instance_url_prefix = cls.get_instance_url_prefix()
5686 model_title_plural = cls.get_model_title_plural()
5687 slug = action.replace("_", "-")
5689 # permission
5690 if not permission_title:
5691 if handler and handler.orientation.value == "export":
5692 permission_title = (
5693 f"Export {model_title_plural} to {handler.get_target_title()}"
5694 )
5695 elif handler: # import
5696 permission_title = (
5697 f"Import {model_title_plural} from {handler.get_source_title()}"
5698 )
5699 else:
5700 # TODO: seriously what else can we do here
5701 permission_title = "TODO: import/export something?"
5702 config.add_wutta_permission(
5703 permission_prefix, f"{permission_prefix}.{action}", permission_title
5704 )
5706 # import full
5707 config.add_route(
5708 f"{route_prefix}.{action}_full",
5709 f"{url_prefix}/{slug}",
5710 )
5711 config.add_view(
5712 cls,
5713 attr=f"{action}_full",
5714 route_name=f"{route_prefix}.{action}_full",
5715 permission=f"{permission_prefix}.{action}",
5716 )
5718 # import single
5719 config.add_route(
5720 f"{route_prefix}.{action}_single",
5721 f"{instance_url_prefix}/{slug}",
5722 request_method="POST",
5723 )
5724 config.add_view(
5725 cls,
5726 attr=f"{action}_single",
5727 route_name=f"{route_prefix}.{action}_single",
5728 permission=f"{permission_prefix}.{action}",
5729 )