Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / master.py: 100%
983 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-02 19:45 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-02 19:45 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base Logic for Master Views
25"""
26# pylint: disable=too-many-lines
28import logging
29import os
30import threading
31import warnings
33import sqlalchemy as sa
34from sqlalchemy import orm
36from pyramid.renderers import render_to_response
37from webhelpers2.html import HTML, tags
39from wuttjamaican.util import get_class_hierarchy
40from wuttaweb.views.base import View
41from wuttaweb.util import get_form_data, render_csrf_token
42from wuttaweb.db import Session
43from wuttaweb.progress import SessionProgress
44from wuttaweb.diffs import VersionDiff
47log = logging.getLogger(__name__)
50class MasterView(View): # pylint: disable=too-many-public-methods
51 """
52 Base class for "master" views.
54 Master views typically map to a table in a DB, though not always.
55 They essentially are a set of CRUD views for a certain type of
56 data record.
58 Many attributes may be overridden in subclass. For instance to
59 define :attr:`model_class`::
61 from wuttaweb.views import MasterView
62 from wuttjamaican.db.model import Person
64 class MyPersonView(MasterView):
65 model_class = Person
67 def includeme(config):
68 MyPersonView.defaults(config)
70 .. note::
72 Many of these attributes will only exist if they have been
73 explicitly defined in a subclass. There are corresponding
74 ``get_xxx()`` methods which should be used instead of accessing
75 these attributes directly.
77 .. attribute:: model_class
79 Optional reference to a :term:`data model` class. While not
80 strictly required, most views will set this to a SQLAlchemy
81 mapped class,
82 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
84 The base logic should not access this directly but instead call
85 :meth:`get_model_class()`.
87 .. attribute:: model_name
89 Optional override for the view's data model name,
90 e.g. ``'WuttaWidget'``.
92 Code should not access this directly but instead call
93 :meth:`get_model_name()`.
95 .. attribute:: model_name_normalized
97 Optional override for the view's "normalized" data model name,
98 e.g. ``'wutta_widget'``.
100 Code should not access this directly but instead call
101 :meth:`get_model_name_normalized()`.
103 .. attribute:: model_title
105 Optional override for the view's "humanized" (singular) model
106 title, e.g. ``"Wutta Widget"``.
108 Code should not access this directly but instead call
109 :meth:`get_model_title()`.
111 .. attribute:: model_title_plural
113 Optional override for the view's "humanized" (plural) model
114 title, e.g. ``"Wutta Widgets"``.
116 Code should not access this directly but instead call
117 :meth:`get_model_title_plural()`.
119 .. attribute:: model_key
121 Optional override for the view's "model key" - e.g. ``'id'``
122 (string for simple case) or composite key such as
123 ``('id_field', 'name_field')``.
125 If :attr:`model_class` is set to a SQLAlchemy mapped class, the
126 model key can be determined automatically.
128 Code should not access this directly but instead call
129 :meth:`get_model_key()`.
131 .. attribute:: grid_key
133 Optional override for the view's grid key, e.g. ``'widgets'``.
135 Code should not access this directly but instead call
136 :meth:`get_grid_key()`.
138 .. attribute:: config_title
140 Optional override for the view's "config" title, e.g. ``"Wutta
141 Widgets"`` (to be displayed as **Configure Wutta Widgets**).
143 Code should not access this directly but instead call
144 :meth:`get_config_title()`.
146 .. attribute:: route_prefix
148 Optional override for the view's route prefix,
149 e.g. ``'wutta_widgets'``.
151 Code should not access this directly but instead call
152 :meth:`get_route_prefix()`.
154 .. attribute:: permission_prefix
156 Optional override for the view's permission prefix,
157 e.g. ``'wutta_widgets'``.
159 Code should not access this directly but instead call
160 :meth:`get_permission_prefix()`.
162 .. attribute:: url_prefix
164 Optional override for the view's URL prefix,
165 e.g. ``'/widgets'``.
167 Code should not access this directly but instead call
168 :meth:`get_url_prefix()`.
170 .. attribute:: template_prefix
172 Optional override for the view's template prefix,
173 e.g. ``'/widgets'``.
175 Code should not access this directly but instead call
176 :meth:`get_template_prefix()`.
178 .. attribute:: listable
180 Boolean indicating whether the view model supports "listing" -
181 i.e. it should have an :meth:`index()` view. Default value is
182 ``True``.
184 .. attribute:: has_grid
186 Boolean indicating whether the :meth:`index()` view should
187 include a grid. Default value is ``True``.
189 .. attribute:: grid_columns
191 List of columns for the :meth:`index()` view grid.
193 This is optional; see also :meth:`get_grid_columns()`.
195 .. attribute:: checkable
197 Boolean indicating whether the grid should expose per-row
198 checkboxes. This is passed along to set
199 :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid.
201 .. method:: grid_row_class(obj, data, i)
203 This method is *not* defined on the ``MasterView`` base class;
204 however if a subclass defines it then it will be automatically
205 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
206 the main :meth:`index()` grid.
208 For more info see
209 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
211 .. attribute:: filterable
213 Boolean indicating whether the grid for the :meth:`index()`
214 view should allow filtering of data. Default is ``True``.
216 This is used by :meth:`make_model_grid()` to set the grid's
217 :attr:`~wuttaweb.grids.base.Grid.filterable` flag.
219 .. attribute:: filter_defaults
221 Optional dict of default filter state.
223 This is used by :meth:`make_model_grid()` to set the grid's
224 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
226 Only relevant if :attr:`filterable` is true.
228 .. attribute:: sortable
230 Boolean indicating whether the grid for the :meth:`index()`
231 view should allow sorting of data. Default is ``True``.
233 This is used by :meth:`make_model_grid()` to set the grid's
234 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
236 See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
238 .. attribute:: sort_on_backend
240 Boolean indicating whether the grid data for the
241 :meth:`index()` view should be sorted on the backend. Default
242 is ``True``.
244 This is used by :meth:`make_model_grid()` to set the grid's
245 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
247 Only relevant if :attr:`sortable` is true.
249 .. attribute:: sort_defaults
251 Optional list of default sorting info. Applicable for both
252 frontend and backend sorting.
254 This is used by :meth:`make_model_grid()` to set the grid's
255 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
257 Only relevant if :attr:`sortable` is true.
259 .. attribute:: paginated
261 Boolean indicating whether the grid data for the
262 :meth:`index()` view should be paginated. Default is ``True``.
264 This is used by :meth:`make_model_grid()` to set the grid's
265 :attr:`~wuttaweb.grids.base.Grid.paginated` flag.
267 .. attribute:: paginate_on_backend
269 Boolean indicating whether the grid data for the
270 :meth:`index()` view should be paginated on the backend.
271 Default is ``True``.
273 This is used by :meth:`make_model_grid()` to set the grid's
274 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
276 .. attribute:: creatable
278 Boolean indicating whether the view model supports "creating" -
279 i.e. it should have a :meth:`create()` view. Default value is
280 ``True``.
282 .. attribute:: viewable
284 Boolean indicating whether the view model supports "viewing" -
285 i.e. it should have a :meth:`view()` view. Default value is
286 ``True``.
288 .. attribute:: editable
290 Boolean indicating whether the view model supports "editing" -
291 i.e. it should have an :meth:`edit()` view. Default value is
292 ``True``.
294 See also :meth:`is_editable()`.
296 .. attribute:: deletable
298 Boolean indicating whether the view model supports "deleting" -
299 i.e. it should have a :meth:`delete()` view. Default value is
300 ``True``.
302 See also :meth:`is_deletable()`.
304 .. attribute:: deletable_bulk
306 Boolean indicating whether the view model supports "bulk
307 deleting" - i.e. it should have a :meth:`delete_bulk()` view.
308 Default value is ``False``.
310 See also :attr:`deletable_bulk_quick`.
312 .. attribute:: deletable_bulk_quick
314 Boolean indicating whether the view model supports "quick" bulk
315 deleting, i.e. the operation is reliably quick enough that it
316 should happen *synchronously* with no progress indicator.
318 Default is ``False`` in which case a progress indicator is
319 shown while the bulk deletion is performed.
321 Only relevant if :attr:`deletable_bulk` is true.
323 .. attribute:: form_fields
325 List of fields for the model form.
327 This is optional; see also :meth:`get_form_fields()`.
329 .. attribute:: has_autocomplete
331 Boolean indicating whether the view model supports
332 "autocomplete" - i.e. it should have an :meth:`autocomplete()`
333 view. Default is ``False``.
335 .. attribute:: downloadable
337 Boolean indicating whether the view model supports
338 "downloading" - i.e. it should have a :meth:`download()` view.
339 Default is ``False``.
341 .. attribute:: executable
343 Boolean indicating whether the view model supports "executing"
344 - i.e. it should have an :meth:`execute()` view. Default is
345 ``False``.
347 .. attribute:: configurable
349 Boolean indicating whether the master view supports
350 "configuring" - i.e. it should have a :meth:`configure()` view.
351 Default value is ``False``.
353 .. attribute:: version_grid_columns
355 List of columns for the :meth:`view_versions()` view grid.
357 This is optional; see also :meth:`get_version_grid_columns()`.
359 **ROW FEATURES**
361 .. attribute:: has_rows
363 Whether the model has "child rows" which should also be
364 displayed when viewing model records. For instance when
365 viewing a :term:`batch` you want to see both the batch header
366 as well as its row data.
368 This the "master switch" for all row features; if this is turned
369 on then many other things kick in.
371 See also :attr:`row_model_class`.
373 .. attribute:: row_model_class
375 Reference to the :term:`data model` class for the child rows.
377 Subclass should define this if :attr:`has_rows` is true.
379 View logic should not access this directly but instead call
380 :meth:`get_row_model_class()`.
382 .. attribute:: row_model_name
384 Optional override for the view's row model name,
385 e.g. ``'WuttaWidget'``.
387 Code should not access this directly but instead call
388 :meth:`get_row_model_name()`.
390 .. attribute:: row_model_title
392 Optional override for the view's "humanized" (singular) row
393 model title, e.g. ``"Wutta Widget"``.
395 Code should not access this directly but instead call
396 :meth:`get_row_model_title()`.
398 .. attribute:: row_model_title_plural
400 Optional override for the view's "humanized" (plural) row model
401 title, e.g. ``"Wutta Widgets"``.
403 Code should not access this directly but instead call
404 :meth:`get_row_model_title_plural()`.
406 .. attribute:: rows_title
408 Display title for the rows grid.
410 The base logic should not access this directly but instead call
411 :meth:`get_rows_title()`.
413 .. attribute:: row_grid_columns
415 List of columns for the row grid.
417 This is optional; see also :meth:`get_row_grid_columns()`.
419 This is optional; see also :meth:`get_row_grid_columns()`.
421 .. attribute:: rows_viewable
423 Boolean indicating whether the row model supports "viewing" -
424 i.e. the row grid should have a "View" action. Default value
425 is ``False``.
427 (For now) If you enable this, you must also override
428 :meth:`get_row_action_url_view()`.
430 .. note::
431 This eventually will cause there to be a ``row_view`` route
432 to be configured as well.
434 .. attribute:: row_form_fields
436 List of fields for the row model form.
438 This is optional; see also :meth:`get_row_form_fields()`.
440 .. attribute:: rows_creatable
442 Boolean indicating whether the row model supports "creating" -
443 i.e. a route should be defined for :meth:`create_row()`.
444 Default value is ``False``.
445 """
447 ##############################
448 # attributes
449 ##############################
451 model_class = None
453 # features
454 listable = True
455 has_grid = True
456 checkable = False
457 filterable = True
458 filter_defaults = None
459 sortable = True
460 sort_on_backend = True
461 sort_defaults = None
462 paginated = True
463 paginate_on_backend = True
464 creatable = True
465 viewable = True
466 editable = True
467 deletable = True
468 deletable_bulk = False
469 deletable_bulk_quick = False
470 has_autocomplete = False
471 downloadable = False
472 executable = False
473 execute_progress_template = None
474 configurable = False
476 # row features
477 has_rows = False
478 row_model_class = None
479 rows_filterable = True
480 rows_filter_defaults = None
481 rows_sortable = True
482 rows_sort_on_backend = True
483 rows_sort_defaults = None
484 rows_paginated = True
485 rows_paginate_on_backend = True
486 rows_viewable = False
487 rows_creatable = False
489 # current action
490 listing = False
491 creating = False
492 viewing = False
493 editing = False
494 deleting = False
495 executing = False
496 configuring = False
498 # default DB session
499 Session = Session
501 ##############################
502 # index methods
503 ##############################
505 def index(self):
506 """
507 View to "list" (filter/browse) the model data.
509 This is the "default" view for the model and is what user sees
510 when visiting the "root" path under the :attr:`url_prefix`,
511 e.g. ``/widgets/``.
513 By default, this view is included only if :attr:`listable` is
514 true.
516 The default view logic will show a "grid" (table) with the
517 model data (unless :attr:`has_grid` is false).
519 See also related methods, which are called by this one:
521 * :meth:`make_model_grid()`
522 """
523 self.listing = True
525 context = {
526 "index_url": None, # nb. avoid title link since this *is* the index
527 }
529 if self.has_grid:
530 grid = self.make_model_grid()
532 # handle "full" vs. "partial" differently
533 if self.request.GET.get("partial"):
535 # so-called 'partial' requests get just data, no html
536 context = grid.get_vue_context()
537 if grid.paginated and grid.paginate_on_backend:
538 context["pager_stats"] = grid.get_vue_pager_stats()
539 return self.json_response(context)
541 # full, not partial
543 # nb. when user asks to reset view, it is via the query
544 # string. if so we then redirect to discard that.
545 if self.request.GET.get("reset-view"):
547 # nb. we want to preserve url hash if applicable
548 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
549 return self.redirect(self.request.current_route_url(**kw))
551 context["grid"] = grid
553 return self.render_to_response("index", context)
555 ##############################
556 # create methods
557 ##############################
559 def create(self):
560 """
561 View to "create" a new model record.
563 This usually corresponds to URL like ``/widgets/new``
565 By default, this route is included only if :attr:`creatable`
566 is true.
568 The default logic calls :meth:`make_create_form()` and shows
569 that to the user. When they submit valid data, it calls
570 :meth:`save_create_form()` and then
571 :meth:`redirect_after_create()`.
572 """
573 self.creating = True
574 form = self.make_create_form()
576 if form.validate():
577 session = self.Session()
578 try:
579 result = self.save_create_form(form)
580 # nb. must always flush to ensure primary key is set
581 session.flush()
582 except Exception as err: # pylint: disable=broad-exception-caught
583 log.warning("failed to save 'create' form", exc_info=True)
584 self.request.session.flash(f"Create failed: {err}", "error")
585 else:
586 return self.redirect_after_create(result)
588 context = {"form": form}
589 return self.render_to_response("create", context)
591 def make_create_form(self):
592 """
593 Make the "create" model form. This is called by
594 :meth:`create()`.
596 Default logic calls :meth:`make_model_form()`.
598 :returns: :class:`~wuttaweb.forms.base.Form` instance
599 """
600 return self.make_model_form(cancel_url_fallback=self.get_index_url())
602 def save_create_form(self, form):
603 """
604 Save the "create" form. This is called by :meth:`create()`.
606 Default logic calls :meth:`objectify()` and then
607 :meth:`persist()`. Subclass is expected to override for
608 non-standard use cases.
610 As for return value, by default it will be whatever came back
611 from the ``objectify()`` call. In practice a subclass can
612 return whatever it likes. The value is only used as input to
613 :meth:`redirect_after_create()`.
615 :returns: Usually the model instance, but can be "anything"
616 """
617 if hasattr(self, "create_save_form"): # pragma: no cover
618 warnings.warn(
619 "MasterView.create_save_form() method name is deprecated; "
620 f"please refactor to save_create_form() instead for {self.__class__.__name__}",
621 DeprecationWarning,
622 )
623 return self.create_save_form(form)
625 obj = self.objectify(form)
626 self.persist(obj)
627 return obj
629 def redirect_after_create(self, result):
630 """
631 Must return a redirect, following successful save of the
632 "create" form. This is called by :meth:`create()`.
634 By default this redirects to the "view" page for the new
635 record.
637 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
638 """
639 return self.redirect(self.get_action_url("view", result))
641 ##############################
642 # view methods
643 ##############################
645 def view(self):
646 """
647 View to "view" a model record.
649 This usually corresponds to URL like ``/widgets/XXX``
651 By default, this route is included only if :attr:`viewable` is
652 true.
654 The default logic here is as follows:
656 First, if :attr:`has_rows` is true then
657 :meth:`make_row_model_grid()` is called.
659 If ``has_rows`` is true *and* the request has certain special
660 params relating to the grid, control may exit early. Mainly
661 this happens when a "partial" page is requested, which means
662 we just return grid data and nothing else. (Used for backend
663 sorting and pagination etc.)
665 Otherwise :meth:`make_view_form()` is called, and the template
666 is rendered.
667 """
668 self.viewing = True
669 obj = self.get_instance()
670 context = {"instance": obj}
672 if self.has_rows:
674 # always make the grid first. note that it already knows
675 # to "reset" its params when that is requested.
676 grid = self.make_row_model_grid(obj)
678 # but if user did request a "reset" then we want to
679 # redirect so the query string gets cleared out
680 if self.request.GET.get("reset-view"):
682 # nb. we want to preserve url hash if applicable
683 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
684 return self.redirect(self.request.current_route_url(**kw))
686 # so-called 'partial' requests get just the grid data
687 if self.request.params.get("partial"):
688 context = grid.get_vue_context()
689 if grid.paginated and grid.paginate_on_backend:
690 context["pager_stats"] = grid.get_vue_pager_stats()
691 return self.json_response(context)
693 context["rows_grid"] = grid
695 context["form"] = self.make_view_form(obj)
696 context["xref_buttons"] = self.get_xref_buttons(obj)
697 return self.render_to_response("view", context)
699 def make_view_form(self, obj, readonly=True):
700 """
701 Make the "view" model form. This is called by
702 :meth:`view()`.
704 Default logic calls :meth:`make_model_form()`.
706 :returns: :class:`~wuttaweb.forms.base.Form` instance
707 """
708 return self.make_model_form(obj, readonly=readonly)
710 ##############################
711 # edit methods
712 ##############################
714 def edit(self):
715 """
716 View to "edit" a model record.
718 This usually corresponds to URL like ``/widgets/XXX/edit``
720 By default, this route is included only if :attr:`editable` is
721 true.
723 The default logic calls :meth:`make_edit_form()` and shows
724 that to the user. When they submit valid data, it calls
725 :meth:`save_edit_form()` and then
726 :meth:`redirect_after_edit()`.
727 """
728 self.editing = True
729 instance = self.get_instance()
730 form = self.make_edit_form(instance)
732 if form.validate():
733 try:
734 result = self.save_edit_form(form)
735 except Exception as err: # pylint: disable=broad-exception-caught
736 log.warning("failed to save 'edit' form", exc_info=True)
737 self.request.session.flash(f"Edit failed: {err}", "error")
738 else:
739 return self.redirect_after_edit(result)
741 context = {
742 "instance": instance,
743 "form": form,
744 }
745 return self.render_to_response("edit", context)
747 def make_edit_form(self, obj):
748 """
749 Make the "edit" model form. This is called by
750 :meth:`edit()`.
752 Default logic calls :meth:`make_model_form()`.
754 :returns: :class:`~wuttaweb.forms.base.Form` instance
755 """
756 return self.make_model_form(
757 obj, cancel_url_fallback=self.get_action_url("view", obj)
758 )
760 def save_edit_form(self, form):
761 """
762 Save the "edit" form. This is called by :meth:`edit()`.
764 Default logic calls :meth:`objectify()` and then
765 :meth:`persist()`. Subclass is expected to override for
766 non-standard use cases.
768 As for return value, by default it will be whatever came back
769 from the ``objectify()`` call. In practice a subclass can
770 return whatever it likes. The value is only used as input to
771 :meth:`redirect_after_edit()`.
773 :returns: Usually the model instance, but can be "anything"
774 """
775 if hasattr(self, "edit_save_form"): # pragma: no cover
776 warnings.warn(
777 "MasterView.edit_save_form() method name is deprecated; "
778 f"please refactor to save_edit_form() instead for {self.__class__.__name__}",
779 DeprecationWarning,
780 )
781 return self.edit_save_form(form)
783 obj = self.objectify(form)
784 self.persist(obj)
785 return obj
787 def redirect_after_edit(self, result):
788 """
789 Must return a redirect, following successful save of the
790 "edit" form. This is called by :meth:`edit()`.
792 By default this redirects to the "view" page for the record.
794 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
795 """
796 return self.redirect(self.get_action_url("view", result))
798 ##############################
799 # delete methods
800 ##############################
802 def delete(self):
803 """
804 View to "delete" a model record.
806 This usually corresponds to URL like ``/widgets/XXX/delete``
808 By default, this route is included only if :attr:`deletable`
809 is true.
811 The default logic calls :meth:`make_delete_form()` and shows
812 that to the user. When they submit, it calls
813 :meth:`save_delete_form()` and then
814 :meth:`redirect_after_delete()`.
815 """
816 self.deleting = True
817 instance = self.get_instance()
819 if not self.is_deletable(instance):
820 return self.redirect(self.get_action_url("view", instance))
822 form = self.make_delete_form(instance)
824 # nb. validate() often returns empty dict here
825 if form.validate() is not False:
827 try:
828 result = self.save_delete_form( # pylint: disable=assignment-from-none
829 form
830 )
831 except Exception as err: # pylint: disable=broad-exception-caught
832 log.warning("failed to save 'delete' form", exc_info=True)
833 self.request.session.flash(f"Delete failed: {err}", "error")
834 else:
835 return self.redirect_after_delete(result)
837 context = {
838 "instance": instance,
839 "form": form,
840 }
841 return self.render_to_response("delete", context)
843 def make_delete_form(self, obj):
844 """
845 Make the "delete" model form. This is called by
846 :meth:`delete()`.
848 Default logic calls :meth:`make_model_form()` but with a
849 twist:
851 The form proper is *not* readonly; this ensures the form has a
852 submit button etc. But then all fields in the form are
853 explicitly marked readonly.
855 :returns: :class:`~wuttaweb.forms.base.Form` instance
856 """
857 # nb. this form proper is not readonly..
858 form = self.make_model_form(
859 obj,
860 cancel_url_fallback=self.get_action_url("view", obj),
861 button_label_submit="DELETE Forever",
862 button_icon_submit="trash",
863 button_type_submit="is-danger",
864 )
866 # ..but *all* fields are readonly
867 form.readonly_fields = set(form.fields)
868 return form
870 def save_delete_form(self, form):
871 """
872 Save the "delete" form. This is called by :meth:`delete()`.
874 Default logic calls :meth:`delete_instance()`. Normally
875 subclass would override that for non-standard use cases, but
876 it could also/instead override this method.
878 As for return value, by default this returns ``None``. In
879 practice a subclass can return whatever it likes. The value
880 is only used as input to :meth:`redirect_after_delete()`.
882 :returns: Usually ``None``, but can be "anything"
883 """
884 if hasattr(self, "delete_save_form"): # pragma: no cover
885 warnings.warn(
886 "MasterView.delete_save_form() method name is deprecated; "
887 f"please refactor to save_delete_form() instead for {self.__class__.__name__}",
888 DeprecationWarning,
889 )
890 self.delete_save_form(form)
891 return
893 obj = form.model_instance
894 self.delete_instance(obj)
896 def redirect_after_delete(self, result): # pylint: disable=unused-argument
897 """
898 Must return a redirect, following successful save of the
899 "delete" form. This is called by :meth:`delete()`.
901 By default this redirects back to the :meth:`index()` page.
903 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
904 """
905 return self.redirect(self.get_index_url())
907 def delete_instance(self, obj):
908 """
909 Delete the given model instance.
911 As of yet there is no default logic for this method; it will
912 raise ``NotImplementedError``. Subclass should override if
913 needed.
915 This method is called by :meth:`save_delete_form()`.
916 """
917 session = self.app.get_session(obj)
918 session.delete(obj)
920 def delete_bulk(self):
921 """
922 View to delete all records in the current :meth:`index()` grid
923 data set, i.e. those matching current query.
925 This usually corresponds to a URL like
926 ``/widgets/delete-bulk``.
928 By default, this view is included only if
929 :attr:`deletable_bulk` is true.
931 This view requires POST method. When it is finished deleting,
932 user is redirected back to :meth:`index()` view.
934 Subclass normally should not override this method, but rather
935 one of the related methods which are called (in)directly by
936 this one:
938 * :meth:`delete_bulk_action()`
939 """
941 # get current data set from grid
942 # nb. this must *not* be paginated, we need it all
943 grid = self.make_model_grid(paginated=False)
944 data = grid.get_visible_data()
946 if self.deletable_bulk_quick:
948 # delete it all and go back to listing
949 self.delete_bulk_action(data)
950 return self.redirect(self.get_index_url())
952 # start thread for delete; show progress page
953 route_prefix = self.get_route_prefix()
954 key = f"{route_prefix}.delete_bulk"
955 progress = self.make_progress(key, success_url=self.get_index_url())
956 thread = threading.Thread(
957 target=self.delete_bulk_thread,
958 args=(data,),
959 kwargs={"progress": progress},
960 )
961 thread.start()
962 return self.render_progress(progress)
964 def delete_bulk_thread( # pylint: disable=empty-docstring
965 self, query, progress=None
966 ):
967 """ """
968 session = self.app.make_session()
969 records = query.with_session(session).all()
971 def onerror():
972 log.warning(
973 "failed to delete %s results for %s",
974 len(records),
975 self.get_model_title_plural(),
976 exc_info=True,
977 )
979 self.do_thread_body(
980 self.delete_bulk_action,
981 (records,),
982 {"progress": progress},
983 onerror,
984 session=session,
985 progress=progress,
986 )
988 def delete_bulk_action(self, data, progress=None):
989 """
990 This method performs the actual bulk deletion, for the given
991 data set. This is called via :meth:`delete_bulk()`.
993 Default logic will call :meth:`is_deletable()` for every data
994 record, and if that returns true then it calls
995 :meth:`delete_instance()`. A progress indicator will be
996 updated if one is provided.
998 Subclass should override if needed.
999 """
1000 model_title_plural = self.get_model_title_plural()
1002 def delete(obj, i): # pylint: disable=unused-argument
1003 if self.is_deletable(obj):
1004 self.delete_instance(obj)
1006 self.app.progress_loop(
1007 delete, data, progress, message=f"Deleting {model_title_plural}"
1008 )
1010 def delete_bulk_make_button(self): # pylint: disable=empty-docstring
1011 """ """
1012 route_prefix = self.get_route_prefix()
1014 label = HTML.literal(
1015 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}'
1016 )
1017 button = self.make_button(
1018 label,
1019 variant="is-danger",
1020 icon_left="trash",
1021 **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"},
1022 )
1024 form = HTML.tag(
1025 "form",
1026 method="post",
1027 action=self.request.route_url(f"{route_prefix}.delete_bulk"),
1028 ref="deleteResultsForm",
1029 class_="control",
1030 c=[
1031 render_csrf_token(self.request),
1032 button,
1033 ],
1034 )
1035 return form
1037 ##############################
1038 # version history methods
1039 ##############################
1041 @classmethod
1042 def is_versioned(cls):
1043 """
1044 Returns boolean indicating whether the model class is
1045 configured for SQLAlchemy-Continuum versioning.
1047 The default logic will directly inspect the model class, as
1048 returned by :meth:`get_model_class()`. Or you can override by
1049 setting the ``model_is_versioned`` attribute::
1051 class WidgetView(MasterView):
1052 model_class = Widget
1053 model_is_versioned = False
1055 See also :meth:`should_expose_versions()`.
1057 :returns: ``True`` if the model class is versioned; else
1058 ``False``.
1059 """
1060 if hasattr(cls, "model_is_versioned"):
1061 return cls.model_is_versioned
1063 model_class = cls.get_model_class()
1064 if hasattr(model_class, "__versioned__"):
1065 return True
1067 return False
1069 @classmethod
1070 def get_model_version_class(cls):
1071 """
1072 Returns the version class for the master model class.
1074 Should only be relevant if :meth:`is_versioned()` is true.
1075 """
1076 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1078 return continuum.version_class(cls.get_model_class())
1080 def should_expose_versions(self):
1081 """
1082 Returns boolean indicating whether versioning history should
1083 be exposed for the current user. This will return ``True``
1084 unless any of the following are ``False``:
1086 * :meth:`is_versioned()`
1087 * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()`
1088 * ``self.has_perm("versions")`` - cf. :meth:`has_perm()`
1090 :returns: ``True`` if versioning should be exposed for current
1091 user; else ``False``.
1092 """
1093 if not self.is_versioned():
1094 return False
1096 if not self.app.continuum_is_enabled():
1097 return False
1099 if not self.has_perm("versions"):
1100 return False
1102 return True
1104 def view_versions(self):
1105 """
1106 View to list version history for an object. See also
1107 :meth:`view_version()`.
1109 This usually corresponds to a URL like
1110 ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID
1111 for the record.
1113 By default, this view is included only if
1114 :meth:`is_versioned()` is true.
1116 The default view logic will show a "grid" (table) with the
1117 record's version history.
1119 See also:
1121 * :meth:`make_version_grid()`
1122 """
1123 instance = self.get_instance()
1124 instance_title = self.get_instance_title(instance)
1125 grid = self.make_version_grid(instance)
1127 # return grid data only, if partial page was requested
1128 if self.request.GET.get("partial"):
1129 context = grid.get_vue_context()
1130 if grid.paginated and grid.paginate_on_backend:
1131 context["pager_stats"] = grid.get_vue_pager_stats()
1132 return self.json_response(context)
1134 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1136 instance_link = tags.link_to(
1137 instance_title, self.get_action_url("view", instance)
1138 )
1140 index_title_rendered = HTML.literal("<span> »</span>").join(
1141 [index_link, instance_link]
1142 )
1144 return self.render_to_response(
1145 "view_versions",
1146 {
1147 "index_title_rendered": index_title_rendered,
1148 "instance": instance,
1149 "instance_title": instance_title,
1150 "instance_url": self.get_action_url("view", instance),
1151 "grid": grid,
1152 },
1153 )
1155 def make_version_grid(self, instance=None, **kwargs):
1156 """
1157 Create and return a grid for use with the
1158 :meth:`view_versions()` view.
1160 See also related methods, which are called by this one:
1162 * :meth:`get_version_grid_key()`
1163 * :meth:`get_version_grid_columns()`
1164 * :meth:`get_version_grid_data()`
1165 * :meth:`configure_version_grid()`
1167 :returns: :class:`~wuttaweb.grids.base.Grid` instance
1168 """
1169 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1171 route_prefix = self.get_route_prefix()
1172 # instance = kwargs.pop("instance", None)
1173 if not instance:
1174 instance = self.get_instance()
1176 if "key" not in kwargs:
1177 kwargs["key"] = self.get_version_grid_key()
1179 if "model_class" not in kwargs:
1180 kwargs["model_class"] = continuum.transaction_class(self.get_model_class())
1182 if "columns" not in kwargs:
1183 kwargs["columns"] = self.get_version_grid_columns()
1185 if "data" not in kwargs:
1186 kwargs["data"] = self.get_version_grid_data(instance)
1188 if "actions" not in kwargs:
1189 route = f"{route_prefix}.version"
1191 def url(txn, i): # pylint: disable=unused-argument
1192 return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
1194 kwargs["actions"] = [
1195 self.make_grid_action("view", icon="eye", url=url),
1196 ]
1198 kwargs.setdefault("paginated", True)
1200 grid = self.make_grid(**kwargs)
1201 self.configure_version_grid(grid)
1202 grid.load_settings()
1203 return grid
1205 @classmethod
1206 def get_version_grid_key(cls):
1207 """
1208 Returns the unique key to be used for the version grid, for caching
1209 sort/filter options etc.
1211 This is normally called automatically from :meth:`make_version_grid()`.
1213 :returns: Grid key as string
1214 """
1215 if hasattr(cls, "version_grid_key"):
1216 return cls.version_grid_key
1217 return f"{cls.get_route_prefix()}.history"
1219 def get_version_grid_columns(self):
1220 """
1221 Returns the default list of version grid column names, for the
1222 :meth:`view_versions()` view.
1224 This is normally called automatically by
1225 :meth:`make_version_grid()`.
1227 Subclass may define :attr:`version_grid_columns` for simple
1228 cases, or can override this method if needed.
1230 :returns: List of string column names
1231 """
1232 if hasattr(self, "version_grid_columns"):
1233 return self.version_grid_columns
1235 return [
1236 "id",
1237 "issued_at",
1238 "user",
1239 "remote_addr",
1240 "comment",
1241 ]
1243 def get_version_grid_data(self, instance):
1244 """
1245 Returns the grid data query for the :meth:`view_versions()`
1246 view.
1248 This is normally called automatically by
1249 :meth:`make_version_grid()`.
1251 Default query will locate SQLAlchemy-Continuum ``transaction``
1252 records which are associated with versions of the given model
1253 instance. See also
1254 :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1256 :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
1257 """
1258 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1259 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1260 model_transaction_query,
1261 )
1263 model_class = self.get_model_class()
1264 txncls = continuum.transaction_class(model_class)
1265 query = model_transaction_query(instance)
1266 return query.order_by(txncls.issued_at.desc())
1268 def configure_version_grid(self, g):
1269 """
1270 Configure the grid for the :meth:`view_versions()` view.
1272 This is called automatically by :meth:`make_version_grid()`.
1274 Default logic applies basic customization to the column labels etc.
1275 """
1276 # id
1277 g.set_label("id", "TXN ID")
1278 # g.set_link("id")
1280 # issued_at
1281 g.set_label("issued_at", "Changed")
1282 g.set_link("issued_at")
1283 g.set_sort_defaults("issued_at", "desc")
1285 # user
1286 g.set_label("user", "Changed by")
1287 g.set_link("user")
1289 # remote_addr
1290 g.set_label("remote_addr", "IP Address")
1292 # comment
1293 g.set_renderer("comment", self.render_version_comment)
1295 def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument
1296 self, txn, key, value
1297 ):
1298 return txn.meta.get("comment", "")
1300 def view_version(self): # pylint: disable=too-many-locals
1301 """
1302 View to show diff details for a particular object version.
1303 See also :meth:`view_versions()`.
1305 This usually corresponds to a URL like
1306 ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the
1307 key/ID for the record and YYY represents a
1308 SQLAlchemy-Continuum ``transaction.id``.
1310 By default, this view is included only if
1311 :meth:`is_versioned()` is true.
1313 The default view logic will display a "diff" table showing how
1314 the record's values were changed within a transaction.
1316 See also:
1318 * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`
1319 * :meth:`get_relevant_versions()`
1320 * :class:`~wuttaweb.diffs.VersionDiff`
1321 """
1322 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1323 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1324 model_transaction_query,
1325 )
1327 instance = self.get_instance()
1328 model_class = self.get_model_class()
1329 route_prefix = self.get_route_prefix()
1330 txncls = continuum.transaction_class(model_class)
1331 transactions = model_transaction_query(instance)
1333 txnid = self.request.matchdict["txnid"]
1334 txn = transactions.filter(txncls.id == txnid).first()
1335 if not txn:
1336 raise self.notfound()
1338 prev_url = None
1339 older = (
1340 transactions.filter(txncls.issued_at <= txn.issued_at)
1341 .filter(txncls.id != txnid)
1342 .order_by(txncls.issued_at.desc())
1343 .first()
1344 )
1345 if older:
1346 prev_url = self.request.route_url(
1347 f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id
1348 )
1350 next_url = None
1351 newer = (
1352 transactions.filter(txncls.issued_at >= txn.issued_at)
1353 .filter(txncls.id != txnid)
1354 .order_by(txncls.issued_at)
1355 .first()
1356 )
1357 if newer:
1358 next_url = self.request.route_url(
1359 f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id
1360 )
1362 version_diffs = [
1363 VersionDiff(self.config, version)
1364 for version in self.get_relevant_versions(txn, instance)
1365 ]
1367 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1369 instance_title = self.get_instance_title(instance)
1370 instance_link = tags.link_to(
1371 instance_title, self.get_action_url("view", instance)
1372 )
1374 history_link = tags.link_to(
1375 "history",
1376 self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid),
1377 )
1379 index_title_rendered = HTML.literal("<span> »</span>").join(
1380 [index_link, instance_link, history_link]
1381 )
1383 return self.render_to_response(
1384 "view_version",
1385 {
1386 "index_title_rendered": index_title_rendered,
1387 "instance": instance,
1388 "instance_title": instance_title,
1389 "instance_url": self.get_action_url("versions", instance),
1390 "transaction": txn,
1391 "changed": self.app.render_datetime(txn.issued_at, html=True),
1392 "version_diffs": version_diffs,
1393 "show_prev_next": True,
1394 "prev_url": prev_url,
1395 "next_url": next_url,
1396 },
1397 )
1399 def get_relevant_versions(self, transaction, instance):
1400 """
1401 Should return all version records pertaining to the given
1402 model instance and transaction.
1404 This is normally called from :meth:`view_version()`.
1406 :param transaction: SQLAlchemy-Continuum ``transaction``
1407 record/instance.
1409 :param instance: Instance of the model class.
1411 :returns: List of version records.
1412 """
1413 session = self.Session()
1414 vercls = self.get_model_version_class()
1415 return (
1416 session.query(vercls)
1417 .filter(vercls.transaction == transaction)
1418 .filter(vercls.uuid == instance.uuid)
1419 .all()
1420 )
1422 ##############################
1423 # autocomplete methods
1424 ##############################
1426 def autocomplete(self):
1427 """
1428 View which accepts a single ``term`` param, and returns a JSON
1429 list of autocomplete results to match.
1431 By default, this view is included only if
1432 :attr:`has_autocomplete` is true. It usually maps to a URL
1433 like ``/widgets/autocomplete``.
1435 Subclass generally does not need to override this method, but
1436 rather should override the others which this calls:
1438 * :meth:`autocomplete_data()`
1439 * :meth:`autocomplete_normalize()`
1440 """
1441 term = self.request.GET.get("term", "")
1442 if not term:
1443 return []
1445 data = self.autocomplete_data(term) # pylint: disable=assignment-from-none
1446 if not data:
1447 return []
1449 max_results = 100 # TODO
1451 results = []
1452 for obj in data[:max_results]:
1453 normal = self.autocomplete_normalize(obj)
1454 if normal:
1455 results.append(normal)
1457 return results
1459 def autocomplete_data(self, term): # pylint: disable=unused-argument
1460 """
1461 Should return the data/query for the "matching" model records,
1462 based on autocomplete search term. This is called by
1463 :meth:`autocomplete()`.
1465 Subclass must override this; default logic returns no data.
1467 :param term: String search term as-is from user, e.g. "foo bar".
1469 :returns: List of data records, or SQLAlchemy query.
1470 """
1471 return None
1473 def autocomplete_normalize(self, obj):
1474 """
1475 Should return a "normalized" version of the given model
1476 record, suitable for autocomplete JSON results. This is
1477 called by :meth:`autocomplete()`.
1479 Subclass may need to override this; default logic is
1480 simplistic but will work for basic models. It returns the
1481 "autocomplete results" dict for the object::
1483 {
1484 'value': obj.uuid,
1485 'label': str(obj),
1486 }
1488 The 2 keys shown are required; any other keys will be ignored
1489 by the view logic but may be useful on the frontend widget.
1491 :param obj: Model record/instance.
1493 :returns: Dict of "autocomplete results" format, as shown
1494 above.
1495 """
1496 return {
1497 "value": obj.uuid,
1498 "label": str(obj),
1499 }
1501 ##############################
1502 # download methods
1503 ##############################
1505 def download(self):
1506 """
1507 View to download a file associated with a model record.
1509 This usually corresponds to a URL like
1510 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
1511 for the record.
1513 By default, this view is included only if :attr:`downloadable`
1514 is true.
1516 This method will (try to) locate the file on disk, and return
1517 it as a file download response to the client.
1519 The GET request for this view may contain a ``filename`` query
1520 string parameter, which can be used to locate one of various
1521 files associated with the model record. This filename is
1522 passed to :meth:`download_path()` for locating the file.
1524 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
1526 Subclass normally should not override this method, but rather
1527 one of the related methods which are called (in)directly by
1528 this one:
1530 * :meth:`download_path()`
1531 """
1532 obj = self.get_instance()
1533 filename = self.request.GET.get("filename", None)
1535 path = self.download_path(obj, filename) # pylint: disable=assignment-from-none
1536 if not path or not os.path.exists(path):
1537 return self.notfound()
1539 return self.file_response(path)
1541 def download_path(self, obj, filename): # pylint: disable=unused-argument
1542 """
1543 Should return absolute path on disk, for the given object and
1544 filename. Result will be used to return a file response to
1545 client. This is called by :meth:`download()`.
1547 Default logic always returns ``None``; subclass must override.
1549 :param obj: Refefence to the model instance.
1551 :param filename: Name of file for which to retrieve the path.
1553 :returns: Path to file, or ``None`` if not found.
1555 Note that ``filename`` may be ``None`` in which case the "default"
1556 file path should be returned, if applicable.
1558 If this method returns ``None`` (as it does by default) then
1559 the :meth:`download()` view will return a 404 not found
1560 response.
1561 """
1562 return None
1564 ##############################
1565 # execute methods
1566 ##############################
1568 def execute(self):
1569 """
1570 View to "execute" a model record. Requires a POST request.
1572 This usually corresponds to a URL like
1573 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
1574 for the record.
1576 By default, this view is included only if :attr:`executable` is
1577 true.
1579 Probably this is a "rare" view to implement for a model. But
1580 there are two notable use cases so far, namely:
1582 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
1583 * batches (not yet implemented;
1584 cf. :doc:`rattail-manual:data/batch/index` in Rattail
1585 Manual)
1587 The general idea is to take some "irrevocable" action
1588 associated with the model record. In the case of upgrades, it
1589 is to run the upgrade script. For batches it is to "push
1590 live" the data held within the batch.
1592 Subclass normally should not override this method, but rather
1593 one of the related methods which are called (in)directly by
1594 this one:
1596 * :meth:`execute_instance()`
1597 """
1598 route_prefix = self.get_route_prefix()
1599 model_title = self.get_model_title()
1600 obj = self.get_instance()
1602 # make the progress tracker
1603 progress = self.make_progress(
1604 f"{route_prefix}.execute",
1605 success_msg=f"{model_title} was executed.",
1606 success_url=self.get_action_url("view", obj),
1607 )
1609 # start thread for execute; show progress page
1610 key = self.request.matchdict
1611 thread = threading.Thread(
1612 target=self.execute_thread,
1613 args=(key, self.request.user.uuid),
1614 kwargs={"progress": progress},
1615 )
1616 thread.start()
1617 return self.render_progress(
1618 progress,
1619 context={
1620 "instance": obj,
1621 },
1622 template=self.execute_progress_template,
1623 )
1625 def execute_instance(self, obj, user, progress=None):
1626 """
1627 Perform the actual "execution" logic for a model record.
1628 Called by :meth:`execute()`.
1630 This method does nothing by default; subclass must override.
1632 :param obj: Reference to the model instance.
1634 :param user: Reference to the
1635 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1636 is doing the execute.
1638 :param progress: Optional progress indicator factory.
1639 """
1641 def execute_thread( # pylint: disable=empty-docstring
1642 self, key, user_uuid, progress=None
1643 ):
1644 """ """
1645 model = self.app.model
1646 model_title = self.get_model_title()
1648 # nb. use new session, separate from web transaction
1649 session = self.app.make_session()
1651 # fetch model instance and user for this session
1652 obj = self.get_instance(session=session, matchdict=key)
1653 user = session.get(model.User, user_uuid)
1655 try:
1656 self.execute_instance(obj, user, progress=progress)
1658 except Exception as error: # pylint: disable=broad-exception-caught
1659 session.rollback()
1660 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1661 if progress:
1662 progress.handle_error(error)
1664 else:
1665 session.commit()
1666 if progress:
1667 progress.handle_success()
1669 finally:
1670 session.close()
1672 ##############################
1673 # configure methods
1674 ##############################
1676 def configure(self, session=None):
1677 """
1678 View for configuring aspects of the app which are pertinent to
1679 this master view and/or model.
1681 By default, this view is included only if :attr:`configurable`
1682 is true. It usually maps to a URL like ``/widgets/configure``.
1684 The expected workflow is as follows:
1686 * user navigates to Configure page
1687 * user modifies settings and clicks Save
1688 * this view then *deletes* all "known" settings
1689 * then it saves user-submitted settings
1691 That is unless ``remove_settings`` is requested, in which case
1692 settings are deleted but then none are saved. The "known"
1693 settings by default include only the "simple" settings.
1695 As a general rule, a particular setting should be configurable
1696 by (at most) one master view. Some settings may never be
1697 exposed at all. But when exposing a setting, careful thought
1698 should be given to where it logically/best belongs.
1700 Some settings are "simple" and a master view subclass need
1701 only provide their basic definitions via
1702 :meth:`configure_get_simple_settings()`. If complex settings
1703 are needed, subclass must override one or more other methods
1704 to achieve the aim(s).
1706 See also related methods, used by this one:
1708 * :meth:`configure_get_simple_settings()`
1709 * :meth:`configure_get_context()`
1710 * :meth:`configure_gather_settings()`
1711 * :meth:`configure_remove_settings()`
1712 * :meth:`configure_save_settings()`
1713 """
1714 self.configuring = True
1715 config_title = self.get_config_title()
1717 # was form submitted?
1718 if self.request.method == "POST":
1720 # maybe just remove settings
1721 if self.request.POST.get("remove_settings"):
1722 self.configure_remove_settings(session=session)
1723 self.request.session.flash(
1724 f"All settings for {config_title} have been removed.", "warning"
1725 )
1727 # reload configure page
1728 return self.redirect(self.request.current_route_url())
1730 # gather/save settings
1731 data = get_form_data(self.request)
1732 settings = self.configure_gather_settings(data)
1733 self.configure_remove_settings(session=session)
1734 self.configure_save_settings(settings, session=session)
1735 self.request.session.flash("Settings have been saved.")
1737 # reload configure page
1738 return self.redirect(self.request.url)
1740 # render configure page
1741 context = self.configure_get_context()
1742 return self.render_to_response("configure", context)
1744 def configure_get_context(
1745 self,
1746 simple_settings=None,
1747 ):
1748 """
1749 Returns the full context dict, for rendering the
1750 :meth:`configure()` page template.
1752 Default context will include ``simple_settings`` (normalized
1753 to just name/value).
1755 You may need to override this method, to add additional
1756 "complex" settings etc.
1758 :param simple_settings: Optional list of simple settings, if
1759 already initialized. Otherwise it is retrieved via
1760 :meth:`configure_get_simple_settings()`.
1762 :returns: Context dict for the page template.
1763 """
1764 context = {}
1766 # simple settings
1767 if simple_settings is None:
1768 simple_settings = self.configure_get_simple_settings()
1769 if simple_settings:
1771 # we got some, so "normalize" each definition to name/value
1772 normalized = {}
1773 for simple in simple_settings:
1775 # name
1776 name = simple["name"]
1778 # value
1779 if "value" in simple:
1780 value = simple["value"]
1781 elif simple.get("type") is bool:
1782 value = self.config.get_bool(
1783 name, default=simple.get("default", False)
1784 )
1785 else:
1786 value = self.config.get(name, default=simple.get("default"))
1788 normalized[name] = value
1790 # add to template context
1791 context["simple_settings"] = normalized
1793 return context
1795 def configure_get_simple_settings(self):
1796 """
1797 This should return a list of "simple" setting definitions for
1798 the :meth:`configure()` view, which can be handled in a more
1799 automatic way. (This is as opposed to some settings which are
1800 more complex and must be handled manually; those should not be
1801 part of this method's return value.)
1803 Basically a "simple" setting is one which can be represented
1804 by a single field/widget on the Configure page.
1806 The setting definitions returned must each be a dict of
1807 "attributes" for the setting. For instance a *very* simple
1808 setting might be::
1810 {'name': 'wutta.app_title'}
1812 The ``name`` is required, everything else is optional. Here
1813 is a more complete example::
1815 {
1816 'name': 'wutta.production',
1817 'type': bool,
1818 'default': False,
1819 'save_if_empty': False,
1820 }
1822 Note that if specified, the ``default`` should be of the same
1823 data type as defined for the setting (``bool`` in the above
1824 example). The default ``type`` is ``str``.
1826 Normally if a setting's value is effectively null, the setting
1827 is removed instead of keeping it in the DB. This behavior can
1828 be changed per-setting via the ``save_if_empty`` flag.
1830 :returns: List of setting definition dicts as described above.
1831 Note that their order does not matter since the template
1832 must explicitly define field layout etc.
1833 """
1834 return []
1836 def configure_gather_settings(
1837 self,
1838 data,
1839 simple_settings=None,
1840 ):
1841 """
1842 Collect the full set of "normalized" settings from user
1843 request, so that :meth:`configure()` can save them.
1845 Settings are gathered from the given request (e.g. POST)
1846 ``data``, but also taking into account what we know based on
1847 the simple setting definitions.
1849 Subclass may need to override this method if complex settings
1850 are required.
1852 :param data: Form data submitted via POST request.
1854 :param simple_settings: Optional list of simple settings, if
1855 already initialized. Otherwise it is retrieved via
1856 :meth:`configure_get_simple_settings()`.
1858 This method must return a list of normalized settings, similar
1859 in spirit to the definition syntax used in
1860 :meth:`configure_get_simple_settings()`. However the format
1861 returned here is minimal and contains just name/value::
1863 {
1864 'name': 'wutta.app_title',
1865 'value': 'Wutta Wutta',
1866 }
1868 Note that the ``value`` will always be a string.
1870 Also note, whereas it's possible ``data`` will not contain all
1871 known settings, the return value *should* (potentially)
1872 contain all of them.
1874 The one exception is when a simple setting has null value, by
1875 default it will not be included in the result (hence, not
1876 saved to DB) unless the setting definition has the
1877 ``save_if_empty`` flag set.
1878 """
1879 settings = []
1881 # simple settings
1882 if simple_settings is None:
1883 simple_settings = self.configure_get_simple_settings()
1884 if simple_settings:
1886 # we got some, so "normalize" each definition to name/value
1887 for simple in simple_settings:
1888 name = simple["name"]
1890 if name in data:
1891 value = data[name]
1892 elif simple.get("type") is bool:
1893 # nb. bool false will be *missing* from data
1894 value = False
1895 else:
1896 value = simple.get("default")
1898 if simple.get("type") is bool:
1899 value = str(bool(value)).lower()
1900 elif simple.get("type") is int:
1901 value = str(int(value or "0"))
1902 elif value is None:
1903 value = ""
1904 else:
1905 value = str(value)
1907 # only want to save this setting if we received a
1908 # value, or if empty values are okay to save
1909 if value or simple.get("save_if_empty"):
1910 settings.append({"name": name, "value": value})
1912 return settings
1914 def configure_remove_settings(
1915 self,
1916 simple_settings=None,
1917 session=None,
1918 ):
1919 """
1920 Remove all "known" settings from the DB; this is called by
1921 :meth:`configure()`.
1923 The point of this method is to ensure *all* "known" settings
1924 which are managed by this master view, are purged from the DB.
1926 The default logic can handle this automatically for simple
1927 settings; subclass must override for any complex settings.
1929 :param simple_settings: Optional list of simple settings, if
1930 already initialized. Otherwise it is retrieved via
1931 :meth:`configure_get_simple_settings()`.
1932 """
1933 names = []
1935 # simple settings
1936 if simple_settings is None:
1937 simple_settings = self.configure_get_simple_settings()
1938 if simple_settings:
1939 names.extend([simple["name"] for simple in simple_settings])
1941 if names:
1942 # nb. must avoid self.Session here in case that does not
1943 # point to our primary app DB
1944 session = session or self.Session()
1945 for name in names:
1946 self.app.delete_setting(session, name)
1948 def configure_save_settings(self, settings, session=None):
1949 """
1950 Save the given settings to the DB; this is called by
1951 :meth:`configure()`.
1953 This method expects a list of name/value dicts and will simply
1954 save each to the DB, with no "conversion" logic.
1956 :param settings: List of normalized setting definitions, as
1957 returned by :meth:`configure_gather_settings()`.
1958 """
1959 # nb. must avoid self.Session here in case that does not point
1960 # to our primary app DB
1961 session = session or self.Session()
1962 for setting in settings:
1963 self.app.save_setting(
1964 session, setting["name"], setting["value"], force_create=True
1965 )
1967 ##############################
1968 # grid rendering methods
1969 ##############################
1971 def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument
1972 """
1973 Custom grid value renderer for "boolean" fields.
1975 This converts a bool value to "Yes" or "No" - unless the value
1976 is ``None`` in which case this renders empty string.
1977 To use this feature for your grid::
1979 grid.set_renderer('my_bool_field', self.grid_render_bool)
1980 """
1981 if value is None:
1982 return None
1984 return "Yes" if value else "No"
1986 def grid_render_currency(self, record, key, value, scale=2):
1987 """
1988 Custom grid value renderer for "currency" fields.
1990 This expects float or decimal values, and will round the
1991 decimal as appropriate, and add the currency symbol.
1993 :param scale: Number of decimal digits to be displayed;
1994 default is 2 places.
1996 To use this feature for your grid::
1998 grid.set_renderer('my_currency_field', self.grid_render_currency)
2000 # you can also override scale
2001 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
2002 """
2004 # nb. get new value since the one provided will just be a
2005 # (json-safe) *string* if the original type was Decimal
2006 value = record[key]
2008 if value is None:
2009 return None
2011 if value < 0:
2012 fmt = f"(${{:0,.{scale}f}})"
2013 return fmt.format(0 - value)
2015 fmt = f"${{:0,.{scale}f}}"
2016 return fmt.format(value)
2018 def grid_render_datetime( # pylint: disable=empty-docstring
2019 self, record, key, value, fmt=None
2020 ):
2021 """ """
2022 warnings.warn(
2023 "MasterView.grid_render_datetime() is deprecated; "
2024 "please use app.render_datetime() directly instead",
2025 DeprecationWarning,
2026 stacklevel=2,
2027 )
2029 # nb. get new value since the one provided will just be a
2030 # (json-safe) *string* if the original type was datetime
2031 value = record[key]
2033 if value is None:
2034 return None
2036 return value.strftime(fmt or "%Y-%m-%d %I:%M:%S %p")
2038 def grid_render_enum(self, record, key, value, enum=None):
2039 """
2040 Custom grid value renderer for "enum" fields.
2042 :param enum: Enum class for the field. This should be an
2043 instance of :class:`~python:enum.Enum`.
2045 To use this feature for your grid::
2047 from enum import Enum
2049 class MyEnum(Enum):
2050 ONE = 1
2051 TWO = 2
2052 THREE = 3
2054 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
2055 """
2056 if enum:
2057 original = record[key]
2058 if original:
2059 return original.name
2061 return value
2063 def grid_render_notes( # pylint: disable=unused-argument
2064 self, record, key, value, maxlen=100
2065 ):
2066 """
2067 Custom grid value renderer for "notes" fields.
2069 If the given text ``value`` is shorter than ``maxlen``
2070 characters, it is returned as-is.
2072 But if it is longer, then it is truncated and an ellispsis is
2073 added. The resulting ``<span>`` tag is also given a ``title``
2074 attribute with the original (full) text, so that appears on
2075 mouse hover.
2077 To use this feature for your grid::
2079 grid.set_renderer('my_notes_field', self.grid_render_notes)
2081 # you can also override maxlen
2082 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
2083 """
2084 if value is None:
2085 return None
2087 if len(value) < maxlen:
2088 return value
2090 return HTML.tag("span", title=value, c=f"{value[:maxlen]}...")
2092 ##############################
2093 # support methods
2094 ##############################
2096 def get_class_hierarchy(self, topfirst=True):
2097 """
2098 Convenience to return a list of classes from which the current
2099 class inherits.
2101 This is a wrapper around
2102 :func:`wuttjamaican.util.get_class_hierarchy()`.
2103 """
2104 return get_class_hierarchy(self.__class__, topfirst=topfirst)
2106 def has_perm(self, name):
2107 """
2108 Shortcut to check if current user has the given permission.
2110 This will automatically add the :attr:`permission_prefix` to
2111 ``name`` before passing it on to
2112 :func:`~wuttaweb.subscribers.request.has_perm()`.
2114 For instance within the
2115 :class:`~wuttaweb.views.users.UserView` these give the same
2116 result::
2118 self.request.has_perm('users.edit')
2120 self.has_perm('edit')
2122 So this shortcut only applies to permissions defined for the
2123 current master view. The first example above must still be
2124 used to check for "foreign" permissions (i.e. any needing a
2125 different prefix).
2126 """
2127 permission_prefix = self.get_permission_prefix()
2128 return self.request.has_perm(f"{permission_prefix}.{name}")
2130 def has_any_perm(self, *names):
2131 """
2132 Shortcut to check if current user has any of the given
2133 permissions.
2135 This calls :meth:`has_perm()` until one returns ``True``. If
2136 none do, returns ``False``.
2137 """
2138 for name in names:
2139 if self.has_perm(name):
2140 return True
2141 return False
2143 def make_button(
2144 self,
2145 label,
2146 variant=None,
2147 primary=False,
2148 url=None,
2149 **kwargs,
2150 ):
2151 """
2152 Make and return a HTML ``<b-button>`` literal.
2154 :param label: Text label for the button.
2156 :param variant: This is the "Buefy type" (or "Oruga variant")
2157 for the button. Buefy and Oruga represent this differently
2158 but this logic expects the Buefy format
2159 (e.g. ``is-danger``) and *not* the Oruga format
2160 (e.g. ``danger``), despite the param name matching Oruga's
2161 terminology.
2163 :param type: This param is not advertised in the method
2164 signature, but if caller specifies ``type`` instead of
2165 ``variant`` it should work the same.
2167 :param primary: If neither ``variant`` nor ``type`` are
2168 specified, this flag may be used to automatically set the
2169 Buefy type to ``is-primary``.
2171 This is the preferred method where applicable, since it
2172 avoids the Buefy vs. Oruga confusion, and the
2173 implementation can change in the future.
2175 :param url: Specify this (instead of ``href``) to make the
2176 button act like a link. This will yield something like:
2177 ``<b-button tag="a" href="{url}">``
2179 :param \\**kwargs: All remaining kwargs are passed to the
2180 underlying ``HTML.tag()`` call, so will be rendered as
2181 attributes on the button tag.
2183 **NB.** You cannot specify a ``tag`` kwarg, for technical
2184 reasons.
2186 :returns: HTML literal for the button element. Will be something
2187 along the lines of:
2189 .. code-block::
2191 <b-button type="is-primary"
2192 icon-pack="fas"
2193 icon-left="hand-pointer">
2194 Click Me
2195 </b-button>
2196 """
2197 btn_kw = kwargs
2198 btn_kw.setdefault("c", label)
2199 btn_kw.setdefault("icon_pack", "fas")
2201 if "type" not in btn_kw:
2202 if variant:
2203 btn_kw["type"] = variant
2204 elif primary:
2205 btn_kw["type"] = "is-primary"
2207 if url:
2208 btn_kw["href"] = url
2210 button = HTML.tag("b-button", **btn_kw)
2212 if url:
2213 # nb. unfortunately HTML.tag() calls its first arg 'tag'
2214 # and so we can't pass a kwarg with that name...so instead
2215 # we patch that into place manually
2216 button = str(button)
2217 button = button.replace("<b-button ", '<b-button tag="a" ')
2218 button = HTML.literal(button)
2220 return button
2222 def get_xref_buttons(self, obj): # pylint: disable=unused-argument
2223 """
2224 Should return a list of "cross-reference" buttons to be shown
2225 when viewing the given object.
2227 Default logic always returns empty list; subclass can override
2228 as needed.
2230 If applicable, this method should do its own permission checks
2231 and only include the buttons current user should be allowed to
2232 see/use.
2234 See also :meth:`make_button()` - example::
2236 def get_xref_buttons(self, product):
2237 buttons = []
2238 if self.request.has_perm('external_products.view'):
2239 url = self.request.route_url('external_products.view',
2240 id=product.external_id)
2241 buttons.append(self.make_button("View External", url=url))
2242 return buttons
2243 """
2244 return []
2246 def make_progress(self, key, **kwargs):
2247 """
2248 Create and return a
2249 :class:`~wuttaweb.progress.SessionProgress` instance, with the
2250 given key.
2252 This is normally done just before calling
2253 :meth:`render_progress()`.
2254 """
2255 return SessionProgress(self.request, key, **kwargs)
2257 def render_progress(self, progress, context=None, template=None):
2258 """
2259 Render the progress page, with given template/context.
2261 When a view method needs to start a long-running operation, it
2262 first starts a thread to do the work, and then it renders the
2263 "progress" page. As the operation continues the progress page
2264 is updated. When the operation completes (or fails) the user
2265 is redirected to the final destination.
2267 TODO: should document more about how to do this..
2269 :param progress: Progress indicator instance as returned by
2270 :meth:`make_progress()`.
2272 :returns: A :term:`response` with rendered progress page.
2273 """
2274 template = template or "/progress.mako"
2275 context = context or {}
2276 context["progress"] = progress
2277 return render_to_response(template, context, request=self.request)
2279 def render_to_response(self, template, context):
2280 """
2281 Locate and render an appropriate template, with the given
2282 context, and return a :term:`response`.
2284 The specified ``template`` should be only the "base name" for
2285 the template - e.g. ``'index'`` or ``'edit'``. This method
2286 will then try to locate a suitable template file, based on
2287 values from :meth:`get_template_prefix()` and
2288 :meth:`get_fallback_templates()`.
2290 In practice this *usually* means two different template paths
2291 will be attempted, e.g. if ``template`` is ``'edit'`` and
2292 :attr:`template_prefix` is ``'/widgets'``:
2294 * ``/widgets/edit.mako``
2295 * ``/master/edit.mako``
2297 The first template found to exist will be used for rendering.
2298 It then calls
2299 :func:`pyramid:pyramid.renderers.render_to_response()` and
2300 returns the result.
2302 :param template: Base name for the template.
2304 :param context: Data dict to be used as template context.
2306 :returns: Response object containing the rendered template.
2307 """
2308 defaults = {
2309 "master": self,
2310 "route_prefix": self.get_route_prefix(),
2311 "index_title": self.get_index_title(),
2312 "index_url": self.get_index_url(),
2313 "model_title": self.get_model_title(),
2314 "config_title": self.get_config_title(),
2315 }
2317 # merge defaults + caller-provided context
2318 defaults.update(context)
2319 context = defaults
2321 # add crud flags if we have an instance
2322 if "instance" in context:
2323 instance = context["instance"]
2324 if "instance_title" not in context:
2325 context["instance_title"] = self.get_instance_title(instance)
2326 if "instance_editable" not in context:
2327 context["instance_editable"] = self.is_editable(instance)
2328 if "instance_deletable" not in context:
2329 context["instance_deletable"] = self.is_deletable(instance)
2331 # supplement context further if needed
2332 context = self.get_template_context(context)
2334 # first try the template path most specific to this view
2335 page_templates = self.get_page_templates(template)
2336 mako_path = page_templates[0]
2337 try:
2338 return render_to_response(mako_path, context, request=self.request)
2339 except IOError:
2341 # failing that, try one or more fallback templates
2342 for fallback in page_templates[1:]:
2343 try:
2344 return render_to_response(fallback, context, request=self.request)
2345 except IOError:
2346 pass
2348 # if we made it all the way here, then we found no
2349 # templates at all, in which case re-attempt the first and
2350 # let that error raise on up
2351 return render_to_response(mako_path, context, request=self.request)
2353 def get_template_context(self, context):
2354 """
2355 This method should return the "complete" context for rendering
2356 the current view template.
2358 Default logic for this method returns the given context
2359 unchanged.
2361 You may wish to override to pass extra context to the view
2362 template. Check :attr:`viewing` and similar, or
2363 ``request.current_route_name`` etc. in order to add extra
2364 context only for certain view templates.
2366 :params: context: The context dict we have so far,
2367 auto-provided by the master view logic.
2369 :returns: Final context dict for the template.
2370 """
2371 return context
2373 def get_page_templates(self, template):
2374 """
2375 Returns a list of all templates which can be attempted, to
2376 render the current page. This is called by
2377 :meth:`render_to_response()`.
2379 The list should be in order of preference, e.g. the first
2380 entry will be the most "specific" template, with subsequent
2381 entries becoming more generic.
2383 In practice this method defines the first entry but calls
2384 :meth:`get_fallback_templates()` for the rest.
2386 :param template: Base name for a template (without prefix), e.g.
2387 ``'view'``.
2389 :returns: List of template paths to be tried, based on the
2390 specified template. For instance if ``template`` is
2391 ``'view'`` this will (by default) return::
2393 [
2394 '/widgets/view.mako',
2395 '/master/view.mako',
2396 ]
2398 """
2399 template_prefix = self.get_template_prefix()
2400 page_templates = [f"{template_prefix}/{template}.mako"]
2401 page_templates.extend(self.get_fallback_templates(template))
2402 return page_templates
2404 def get_fallback_templates(self, template):
2405 """
2406 Returns a list of "fallback" template paths which may be
2407 attempted for rendering the current page. See also
2408 :meth:`get_page_templates()`.
2410 :param template: Base name for a template (without prefix), e.g.
2411 ``'view'``.
2413 :returns: List of template paths to be tried, based on the
2414 specified template. For instance if ``template`` is
2415 ``'view'`` this will (by default) return::
2417 ['/master/view.mako']
2418 """
2419 return [f"/master/{template}.mako"]
2421 def get_index_title(self):
2422 """
2423 Returns the main index title for the master view.
2425 By default this returns the value from
2426 :meth:`get_model_title_plural()`. Subclass may override as
2427 needed.
2428 """
2429 return self.get_model_title_plural()
2431 def get_index_url(self, **kwargs):
2432 """
2433 Returns the URL for master's :meth:`index()` view.
2435 NB. this returns ``None`` if :attr:`listable` is false.
2436 """
2437 if self.listable:
2438 route_prefix = self.get_route_prefix()
2439 return self.request.route_url(route_prefix, **kwargs)
2440 return None
2442 def set_labels(self, obj):
2443 """
2444 Set label overrides on a form or grid, based on what is
2445 defined by the view class and its parent class(es).
2447 This is called automatically from :meth:`configure_grid()` and
2448 :meth:`configure_form()`.
2450 This calls :meth:`collect_labels()` to find everything, then
2451 it assigns the labels using one of (based on ``obj`` type):
2453 * :func:`wuttaweb.forms.base.Form.set_label()`
2454 * :func:`wuttaweb.grids.base.Grid.set_label()`
2456 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
2457 :class:`~wuttaweb.forms.base.Form` instance.
2458 """
2459 labels = self.collect_labels()
2460 for key, label in labels.items():
2461 obj.set_label(key, label)
2463 def collect_labels(self):
2464 """
2465 Collect all labels defined by the view class and/or its parents.
2467 A master view can declare labels via class-level attribute,
2468 like so::
2470 from wuttaweb.views import MasterView
2472 class WidgetView(MasterView):
2474 labels = {
2475 'id': "Widget ID",
2476 'serial_no': "Serial Number",
2477 }
2479 All such labels, defined by any class from which the master
2480 view inherits, will be returned. However if the same label
2481 key is defined by multiple classes, the "subclass" always
2482 wins.
2484 Labels defined in this way will apply to both forms and grids.
2485 See also :meth:`set_labels()`.
2487 :returns: Dict of all labels found.
2488 """
2489 labels = {}
2490 hierarchy = self.get_class_hierarchy()
2491 for cls in hierarchy:
2492 if hasattr(cls, "labels"):
2493 labels.update(cls.labels)
2494 return labels
2496 def make_model_grid(self, session=None, **kwargs):
2497 """
2498 Create and return a :class:`~wuttaweb.grids.base.Grid`
2499 instance for use with the :meth:`index()` view.
2501 See also related methods, which are called by this one:
2503 * :meth:`get_grid_key()`
2504 * :meth:`get_grid_columns()`
2505 * :meth:`get_grid_data()`
2506 * :meth:`configure_grid()`
2507 """
2508 if "key" not in kwargs:
2509 kwargs["key"] = self.get_grid_key()
2511 if "model_class" not in kwargs:
2512 model_class = self.get_model_class()
2513 if model_class:
2514 kwargs["model_class"] = model_class
2516 if "columns" not in kwargs:
2517 kwargs["columns"] = self.get_grid_columns()
2519 if "data" not in kwargs:
2520 kwargs["data"] = self.get_grid_data(
2521 columns=kwargs["columns"], session=session
2522 )
2524 if "actions" not in kwargs:
2525 actions = []
2527 # TODO: should split this off into index_get_grid_actions() ?
2529 if self.viewable and self.has_perm("view"):
2530 actions.append(
2531 self.make_grid_action(
2532 "view", icon="eye", url=self.get_action_url_view
2533 )
2534 )
2536 if self.editable and self.has_perm("edit"):
2537 actions.append(
2538 self.make_grid_action(
2539 "edit", icon="edit", url=self.get_action_url_edit
2540 )
2541 )
2543 if self.deletable and self.has_perm("delete"):
2544 actions.append(
2545 self.make_grid_action(
2546 "delete",
2547 icon="trash",
2548 url=self.get_action_url_delete,
2549 link_class="has-text-danger",
2550 )
2551 )
2553 kwargs["actions"] = actions
2555 if "tools" not in kwargs:
2556 tools = []
2558 if self.deletable_bulk and self.has_perm("delete_bulk"):
2559 tools.append(("delete-results", self.delete_bulk_make_button()))
2561 kwargs["tools"] = tools
2563 kwargs.setdefault("checkable", self.checkable)
2564 if hasattr(self, "grid_row_class"):
2565 kwargs.setdefault("row_class", self.grid_row_class)
2566 kwargs.setdefault("filterable", self.filterable)
2567 kwargs.setdefault("filter_defaults", self.filter_defaults)
2568 kwargs.setdefault("sortable", self.sortable)
2569 kwargs.setdefault("sort_on_backend", self.sort_on_backend)
2570 kwargs.setdefault("sort_defaults", self.sort_defaults)
2571 kwargs.setdefault("paginated", self.paginated)
2572 kwargs.setdefault("paginate_on_backend", self.paginate_on_backend)
2574 grid = self.make_grid(**kwargs)
2575 self.configure_grid(grid)
2576 grid.load_settings()
2577 return grid
2579 def get_grid_columns(self):
2580 """
2581 Returns the default list of grid column names, for the
2582 :meth:`index()` view.
2584 This is called by :meth:`make_model_grid()`; in the resulting
2585 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2586 :attr:`~wuttaweb.grids.base.Grid.columns`.
2588 This method may return ``None``, in which case the grid may
2589 (try to) generate its own default list.
2591 Subclass may define :attr:`grid_columns` for simple cases, or
2592 can override this method if needed.
2594 Also note that :meth:`configure_grid()` may be used to further
2595 modify the final column set, regardless of what this method
2596 returns. So a common pattern is to declare all "supported"
2597 columns by setting :attr:`grid_columns` but then optionally
2598 remove or replace some of those within
2599 :meth:`configure_grid()`.
2600 """
2601 if hasattr(self, "grid_columns"):
2602 return self.grid_columns
2603 return None
2605 def get_grid_data( # pylint: disable=unused-argument
2606 self, columns=None, session=None
2607 ):
2608 """
2609 Returns the grid data for the :meth:`index()` view.
2611 This is called by :meth:`make_model_grid()`; in the resulting
2612 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2613 :attr:`~wuttaweb.grids.base.Grid.data`.
2615 Default logic will call :meth:`get_query()` and if successful,
2616 return the list from ``query.all()``. Otherwise returns an
2617 empty list. Subclass should override as needed.
2618 """
2619 query = self.get_query(session=session)
2620 if query:
2621 return query
2622 return []
2624 def get_query(self, session=None):
2625 """
2626 Returns the main SQLAlchemy query object for the
2627 :meth:`index()` view. This is called by
2628 :meth:`get_grid_data()`.
2630 Default logic for this method returns a "plain" query on the
2631 :attr:`model_class` if that is defined; otherwise ``None``.
2632 """
2633 model_class = self.get_model_class()
2634 if model_class:
2635 session = session or self.Session()
2636 return session.query(model_class)
2637 return None
2639 def configure_grid(self, grid):
2640 """
2641 Configure the grid for the :meth:`index()` view.
2643 This is called by :meth:`make_model_grid()`.
2645 There is minimal default logic here; subclass should override
2646 as needed. The ``grid`` param will already be "complete" and
2647 ready to use as-is, but this method can further modify it
2648 based on request details etc.
2649 """
2650 if "uuid" in grid.columns:
2651 grid.columns.remove("uuid")
2653 self.set_labels(grid)
2655 # TODO: i thought this was a good idea but if so it
2656 # needs a try/catch in case of no model class
2657 # for key in self.get_model_key():
2658 # grid.set_link(key)
2660 def get_instance(self, session=None, matchdict=None):
2661 """
2662 This should return the appropriate model instance, based on
2663 the ``matchdict`` of model keys.
2665 Normally this is called with no arguments, in which case the
2666 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
2667 will return the "current" model instance based on the request
2668 (route/params).
2670 If a ``matchdict`` is provided then that is used instead, to
2671 obtain the model keys. In the simple/common example of a
2672 "native" model in WuttaWeb, this would look like::
2674 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
2675 obj = self.get_instance(matchdict=keys)
2677 Although some models may have different, possibly composite
2678 key names to use instead. The specific keys this logic is
2679 expecting are the same as returned by :meth:`get_model_key()`.
2681 If this method is unable to locate the instance, it should
2682 raise a 404 error,
2683 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
2685 Default implementation of this method should work okay for
2686 views which define a :attr:`model_class`. For other views
2687 however it will raise ``NotImplementedError``, so subclass
2688 may need to define.
2690 .. warning::
2692 If you are defining this method for a subclass, please note
2693 this point regarding the 404 "not found" logic.
2695 It is *not* enough to simply *return* this 404 response,
2696 you must explicitly *raise* the error. For instance::
2698 def get_instance(self, **kwargs):
2700 # ..try to locate instance..
2701 obj = self.locate_instance_somehow()
2703 if not obj:
2705 # NB. THIS MAY NOT WORK AS EXPECTED
2706 #return self.notfound()
2708 # nb. should always do this in get_instance()
2709 raise self.notfound()
2711 This lets calling code not have to worry about whether or
2712 not this method might return ``None``. It can safely
2713 assume it will get back a model instance, or else a 404
2714 will kick in and control flow goes elsewhere.
2715 """
2716 model_class = self.get_model_class()
2717 if model_class:
2718 session = session or self.Session()
2719 matchdict = matchdict or self.request.matchdict
2721 def filtr(query, model_key):
2722 key = matchdict[model_key]
2723 query = query.filter(getattr(self.model_class, model_key) == key)
2724 return query
2726 query = session.query(model_class)
2728 for key in self.get_model_key():
2729 query = filtr(query, key)
2731 try:
2732 return query.one()
2733 except orm.exc.NoResultFound:
2734 pass
2736 raise self.notfound()
2738 raise NotImplementedError(
2739 "you must define get_instance() method "
2740 f" for view class: {self.__class__}"
2741 )
2743 def get_instance_title(self, instance):
2744 """
2745 Return the human-friendly "title" for the instance, to be used
2746 in the page title when viewing etc.
2748 Default logic returns the value from ``str(instance)``;
2749 subclass may override if needed.
2750 """
2751 return str(instance) or "(no title)"
2753 def get_action_route_kwargs(self, obj):
2754 """
2755 Get a dict of route kwargs for the given object.
2757 This is called from :meth:`get_action_url()` and must return
2758 kwargs suitable for use with ``request.route_url()``.
2760 In practice this should return a dict which has keys for each
2761 field from :meth:`get_model_key()` and values which come from
2762 the object.
2764 :param obj: Model instance object.
2766 :returns: The dict of route kwargs for the object.
2767 """
2768 try:
2769 return {key: obj[key] for key in self.get_model_key()}
2770 except TypeError:
2771 return {key: getattr(obj, key) for key in self.get_model_key()}
2773 def get_action_url(self, action, obj, **kwargs):
2774 """
2775 Generate an "action" URL for the given model instance.
2777 This is a shortcut which generates a route name based on
2778 :meth:`get_route_prefix()` and the ``action`` param.
2780 It calls :meth:`get_action_route_kwargs()` and then passes
2781 those along with route name to ``request.route_url()``, and
2782 returns the result.
2784 :param action: String name for the action, which corresponds
2785 to part of some named route, e.g. ``'view'`` or ``'edit'``.
2787 :param obj: Model instance object.
2789 :param \\**kwargs: Additional kwargs to be passed to
2790 ``request.route_url()``, if needed.
2791 """
2792 kw = self.get_action_route_kwargs(obj)
2793 kw.update(kwargs)
2794 route_prefix = self.get_route_prefix()
2795 return self.request.route_url(f"{route_prefix}.{action}", **kw)
2797 def get_action_url_view(self, obj, i): # pylint: disable=unused-argument
2798 """
2799 Returns the "view" grid action URL for the given object.
2801 Most typically this is like ``/widgets/XXX`` where ``XXX``
2802 represents the object's key/ID.
2804 Calls :meth:`get_action_url()` under the hood.
2805 """
2806 return self.get_action_url("view", obj)
2808 def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument
2809 """
2810 Returns the "edit" grid action URL for the given object, if
2811 applicable.
2813 Most typically this is like ``/widgets/XXX/edit`` where
2814 ``XXX`` represents the object's key/ID.
2816 This first calls :meth:`is_editable()` and if that is false,
2817 this method will return ``None``.
2819 Calls :meth:`get_action_url()` to generate the true URL.
2820 """
2821 if self.is_editable(obj):
2822 return self.get_action_url("edit", obj)
2823 return None
2825 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument
2826 """
2827 Returns the "delete" grid action URL for the given object, if
2828 applicable.
2830 Most typically this is like ``/widgets/XXX/delete`` where
2831 ``XXX`` represents the object's key/ID.
2833 This first calls :meth:`is_deletable()` and if that is false,
2834 this method will return ``None``.
2836 Calls :meth:`get_action_url()` to generate the true URL.
2837 """
2838 if self.is_deletable(obj):
2839 return self.get_action_url("delete", obj)
2840 return None
2842 def is_editable(self, obj): # pylint: disable=unused-argument
2843 """
2844 Returns a boolean indicating whether "edit" should be allowed
2845 for the given model instance (and for current user).
2847 By default this always return ``True``; subclass can override
2848 if needed.
2850 Note that the use of this method implies :attr:`editable` is
2851 true, so the method does not need to check that flag.
2852 """
2853 return True
2855 def is_deletable(self, obj): # pylint: disable=unused-argument
2856 """
2857 Returns a boolean indicating whether "delete" should be
2858 allowed for the given model instance (and for current user).
2860 By default this always return ``True``; subclass can override
2861 if needed.
2863 Note that the use of this method implies :attr:`deletable` is
2864 true, so the method does not need to check that flag.
2865 """
2866 return True
2868 def make_model_form(self, model_instance=None, fields=None, **kwargs):
2869 """
2870 Make a form for the "model" represented by this subclass.
2872 This method is normally called by all CRUD views:
2874 * :meth:`create()`
2875 * :meth:`view()`
2876 * :meth:`edit()`
2877 * :meth:`delete()`
2879 The form need not have a ``model_instance``, as in the case of
2880 :meth:`create()`. And it can be readonly as in the case of
2881 :meth:`view()` and :meth:`delete()`.
2883 If ``fields`` are not provided, :meth:`get_form_fields()` is
2884 called. Usually a subclass will define :attr:`form_fields`
2885 but it's only required if :attr:`model_class` is not set.
2887 Then :meth:`configure_form()` is called, so subclass can go
2888 crazy with that as needed.
2890 :param model_instance: Model instance/record with which to
2891 initialize the form data. Not needed for "create" forms.
2893 :param fields: Optional fields list for the form.
2895 :returns: :class:`~wuttaweb.forms.base.Form` instance
2896 """
2897 if "model_class" not in kwargs:
2898 model_class = self.get_model_class()
2899 if model_class:
2900 kwargs["model_class"] = model_class
2902 kwargs["model_instance"] = model_instance
2904 if not fields:
2905 fields = self.get_form_fields()
2906 if fields:
2907 kwargs["fields"] = fields
2909 form = self.make_form(**kwargs)
2910 self.configure_form(form)
2911 return form
2913 def get_form_fields(self):
2914 """
2915 Returns the initial list of field names for the model form.
2917 This is called by :meth:`make_model_form()`; in the resulting
2918 :class:`~wuttaweb.forms.base.Form` instance, this becomes
2919 :attr:`~wuttaweb.forms.base.Form.fields`.
2921 This method may return ``None``, in which case the form may
2922 (try to) generate its own default list.
2924 Subclass may define :attr:`form_fields` for simple cases, or
2925 can override this method if needed.
2927 Note that :meth:`configure_form()` may be used to further
2928 modify the final field list, regardless of what this method
2929 returns. So a common pattern is to declare all "supported"
2930 fields by setting :attr:`form_fields` but then optionally
2931 remove or replace some in :meth:`configure_form()`.
2932 """
2933 if hasattr(self, "form_fields"):
2934 return self.form_fields
2935 return None
2937 def configure_form(self, form):
2938 """
2939 Configure the given model form, as needed.
2941 This is called by :meth:`make_model_form()` - for multiple
2942 CRUD views (create, view, edit, delete, possibly others).
2944 The default logic here does just one thing: when "editing"
2945 (i.e. in :meth:`edit()` view) then all fields which are part
2946 of the :attr:`model_key` will be marked via
2947 :meth:`set_readonly()` so the user cannot change primary key
2948 values for a record.
2950 Subclass may override as needed. The ``form`` param will
2951 already be "complete" and ready to use as-is, but this method
2952 can further modify it based on request details etc.
2953 """
2954 form.remove("uuid")
2956 self.set_labels(form)
2958 # mark key fields as readonly to prevent edit. see also
2959 # related comments in the objectify() method
2960 if self.editing:
2961 for key in self.get_model_key():
2962 form.set_readonly(key)
2964 def objectify(self, form):
2965 """
2966 Must return a "model instance" object which reflects the
2967 validated form data.
2969 In simple cases this may just return the
2970 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
2972 When dealing with SQLAlchemy models it would return a proper
2973 mapped instance, creating it if necessary.
2975 This is called by various other form-saving methods:
2977 * :meth:`save_create_form()`
2978 * :meth:`save_edit_form()`
2979 * :meth:`create_row_save_form()`
2981 See also :meth:`persist()`.
2983 :param form: Reference to the *already validated*
2984 :class:`~wuttaweb.forms.base.Form` object. See the form's
2985 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
2986 the data.
2987 """
2989 # ColanderAlchemy schema has an objectify() method which will
2990 # return a populated model instance
2991 schema = form.get_schema()
2992 if hasattr(schema, "objectify"):
2993 return schema.objectify(form.validated, context=form.model_instance)
2995 # at this point we likely have no model class, so have to
2996 # assume we're operating on a simple dict record. we (mostly)
2997 # want to return that as-is, unless subclass overrides.
2998 data = dict(form.validated)
3000 # nb. we have a unique scenario when *editing* for a simple
3001 # dict record (no model class). we mark the key fields as
3002 # readonly in configure_form(), so they aren't part of the
3003 # data here, but we need to add them back for sake of
3004 # e.g. generating the 'view' route kwargs for redirect.
3005 if self.editing:
3006 obj = self.get_instance()
3007 for key in self.get_model_key():
3008 if key not in data:
3009 data[key] = obj[key]
3011 return data
3013 def persist(self, obj, session=None):
3014 """
3015 If applicable, this method should persist ("save") the given
3016 object's data (e.g. to DB), creating or updating it as needed.
3018 This is part of the "submit form" workflow; ``obj`` should be
3019 a model instance which already reflects the validated form
3020 data.
3022 Note that there is no default logic here, subclass must
3023 override if needed.
3025 :param obj: Model instance object as produced by
3026 :meth:`objectify()`.
3028 See also :meth:`save_create_form()` and
3029 :meth:`save_edit_form()`, which call this method.
3030 """
3031 model = self.app.model
3032 model_class = self.get_model_class()
3033 if model_class and issubclass(model_class, model.Base):
3035 # add sqlalchemy model to session
3036 session = session or self.Session()
3037 session.add(obj)
3039 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments
3040 self, func, args, kwargs, onerror=None, session=None, progress=None
3041 ):
3042 """
3043 Generic method to invoke for thread operations.
3045 :param func: Callable which performs the actual logic. This
3046 will be wrapped with a try/except statement for error
3047 handling.
3049 :param args: Tuple of positional arguments to pass to the
3050 ``func`` callable.
3052 :param kwargs: Dict of keyword arguments to pass to the
3053 ``func`` callable.
3055 :param onerror: Optional callback to invoke if ``func`` raises
3056 an error. It should not expect any arguments.
3058 :param session: Optional :term:`db session` in effect. Note
3059 that if supplied, it will be *committed* (or rolled back on
3060 error) and *closed* by this method. If you need more
3061 specialized handling, do not use this method (or don't
3062 specify the ``session``).
3064 :param progress: Optional progress factory. If supplied, this
3065 is assumed to be a
3066 :class:`~wuttaweb.progress.SessionProgress` instance, and
3067 it will be updated per success or failure of ``func``
3068 invocation.
3069 """
3070 try:
3071 func(*args, **kwargs)
3073 except Exception as error: # pylint: disable=broad-exception-caught
3074 if session:
3075 session.rollback()
3076 if onerror:
3077 onerror()
3078 else:
3079 log.warning("failed to invoke thread callable: %s", func, exc_info=True)
3080 if progress:
3081 progress.handle_error(error)
3083 else:
3084 if session:
3085 session.commit()
3086 if progress:
3087 progress.handle_success()
3089 finally:
3090 if session:
3091 session.close()
3093 ##############################
3094 # row methods
3095 ##############################
3097 def get_rows_title(self):
3098 """
3099 Returns the display title for model **rows** grid, if
3100 applicable/desired. Only relevant if :attr:`has_rows` is
3101 true.
3103 There is no default here, but subclass may override by
3104 assigning :attr:`rows_title`.
3105 """
3106 if hasattr(self, "rows_title"):
3107 return self.rows_title
3108 return self.get_row_model_title_plural()
3110 def get_row_parent(self, row):
3111 """
3112 This must return the parent object for the given child row.
3113 Only relevant if :attr:`has_rows` is true.
3115 Default logic is not implemented; subclass must override.
3116 """
3117 raise NotImplementedError
3119 def make_row_model_grid(self, obj, **kwargs):
3120 """
3121 Create and return a grid for a record's **rows** data, for use
3122 in :meth:`view()`. Only applicable if :attr:`has_rows` is
3123 true.
3125 :param obj: Current model instance for which rows data is
3126 being displayed.
3128 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
3129 rows data.
3131 See also related methods, which are called by this one:
3133 * :meth:`get_row_grid_key()`
3134 * :meth:`get_row_grid_columns()`
3135 * :meth:`get_row_grid_data()`
3136 * :meth:`configure_row_grid()`
3137 """
3138 if "key" not in kwargs:
3139 kwargs["key"] = self.get_row_grid_key()
3141 if "model_class" not in kwargs:
3142 model_class = self.get_row_model_class()
3143 if model_class:
3144 kwargs["model_class"] = model_class
3146 if "columns" not in kwargs:
3147 kwargs["columns"] = self.get_row_grid_columns()
3149 if "data" not in kwargs:
3150 kwargs["data"] = self.get_row_grid_data(obj)
3152 kwargs.setdefault("filterable", self.rows_filterable)
3153 kwargs.setdefault("filter_defaults", self.rows_filter_defaults)
3154 kwargs.setdefault("sortable", self.rows_sortable)
3155 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend)
3156 kwargs.setdefault("sort_defaults", self.rows_sort_defaults)
3157 kwargs.setdefault("paginated", self.rows_paginated)
3158 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend)
3160 if "actions" not in kwargs:
3161 actions = []
3163 if self.rows_viewable:
3164 actions.append(
3165 self.make_grid_action(
3166 "view", icon="eye", url=self.get_row_action_url_view
3167 )
3168 )
3170 if actions:
3171 kwargs["actions"] = actions
3173 grid = self.make_grid(**kwargs)
3174 self.configure_row_grid(grid)
3175 grid.load_settings()
3176 return grid
3178 def get_row_grid_key(self):
3179 """
3180 Returns the (presumably) unique key to be used for the
3181 **rows** grid in :meth:`view()`. Only relevant if
3182 :attr:`has_rows` is true.
3184 This is called from :meth:`make_row_model_grid()`; in the
3185 resulting grid, this becomes
3186 :attr:`~wuttaweb.grids.base.Grid.key`.
3188 Whereas you can define :attr:`grid_key` for the main grid, the
3189 row grid key is always generated dynamically. This
3190 incorporates the current record key (whose rows are in the
3191 grid) so that the rows grid for each record is unique.
3192 """
3193 parts = [self.get_grid_key()]
3194 for key in self.get_model_key():
3195 parts.append(str(self.request.matchdict[key]))
3196 return ".".join(parts)
3198 def get_row_grid_columns(self):
3199 """
3200 Returns the default list of column names for the **rows**
3201 grid, for use in :meth:`view()`. Only relevant if
3202 :attr:`has_rows` is true.
3204 This is called by :meth:`make_row_model_grid()`; in the
3205 resulting grid, this becomes
3206 :attr:`~wuttaweb.grids.base.Grid.columns`.
3208 This method may return ``None``, in which case the grid may
3209 (try to) generate its own default list.
3211 Subclass may define :attr:`row_grid_columns` for simple cases,
3212 or can override this method if needed.
3214 Also note that :meth:`configure_row_grid()` may be used to
3215 further modify the final column set, regardless of what this
3216 method returns. So a common pattern is to declare all
3217 "supported" columns by setting :attr:`row_grid_columns` but
3218 then optionally remove or replace some of those within
3219 :meth:`configure_row_grid()`.
3220 """
3221 if hasattr(self, "row_grid_columns"):
3222 return self.row_grid_columns
3223 return None
3225 def get_row_grid_data(self, obj):
3226 """
3227 Returns the data for the **rows** grid, for use in
3228 :meth:`view()`. Only relevant if :attr:`has_rows` is true.
3230 This is called by :meth:`make_row_model_grid()`; in the
3231 resulting grid, this becomes
3232 :attr:`~wuttaweb.grids.base.Grid.data`.
3234 Default logic not implemented; subclass must define this.
3235 """
3236 raise NotImplementedError
3238 def configure_row_grid(self, grid):
3239 """
3240 Configure the **rows** grid for use in :meth:`view()`. Only
3241 relevant if :attr:`has_rows` is true.
3243 This is called by :meth:`make_row_model_grid()`.
3245 There is minimal default logic here; subclass should override
3246 as needed. The ``grid`` param will already be "complete" and
3247 ready to use as-is, but this method can further modify it
3248 based on request details etc.
3249 """
3250 grid.remove("uuid")
3251 self.set_row_labels(grid)
3253 def set_row_labels(self, obj):
3254 """
3255 Set label overrides on a **row** form or grid, based on what
3256 is defined by the view class and its parent class(es).
3258 This is called automatically from
3259 :meth:`configure_row_grid()` and
3260 :meth:`configure_row_form()`.
3262 This calls :meth:`collect_row_labels()` to find everything,
3263 then it assigns the labels using one of (based on ``obj``
3264 type):
3266 * :func:`wuttaweb.forms.base.Form.set_label()`
3267 * :func:`wuttaweb.grids.base.Grid.set_label()`
3269 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
3270 :class:`~wuttaweb.forms.base.Form` instance.
3271 """
3272 labels = self.collect_row_labels()
3273 for key, label in labels.items():
3274 obj.set_label(key, label)
3276 def collect_row_labels(self):
3277 """
3278 Collect all **row** labels defined within the view class
3279 hierarchy.
3281 This is called by :meth:`set_row_labels()`.
3283 :returns: Dict of all labels found.
3284 """
3285 labels = {}
3286 hierarchy = self.get_class_hierarchy()
3287 for cls in hierarchy:
3288 if hasattr(cls, "row_labels"):
3289 labels.update(cls.row_labels)
3290 return labels
3292 def get_row_action_url_view(self, row, i):
3293 """
3294 Must return the "view" action url for the given row object.
3296 Only relevant if :attr:`rows_viewable` is true.
3298 There is no default logic; subclass must override if needed.
3299 """
3300 raise NotImplementedError
3302 def create_row(self):
3303 """
3304 View to create a new "child row" record.
3306 This usually corresponds to a URL like ``/widgets/XXX/new-row``.
3308 By default, this view is included only if
3309 :attr:`rows_creatable` is true.
3311 The default "create row" view logic will show a form with
3312 field widgets, allowing user to submit new values which are
3313 then persisted to the DB (assuming typical SQLAlchemy model).
3315 Subclass normally should not override this method, but rather
3316 one of the related methods which are called (in)directly by
3317 this one:
3319 * :meth:`make_row_model_form()`
3320 * :meth:`configure_row_form()`
3321 * :meth:`create_row_save_form()`
3322 * :meth:`redirect_after_create_row()`
3323 """
3324 self.creating = True
3325 parent = self.get_instance()
3326 parent_url = self.get_action_url("view", parent)
3328 form = self.make_row_model_form(cancel_url_fallback=parent_url)
3329 if form.validate():
3330 result = self.create_row_save_form(form)
3331 return self.redirect_after_create_row(result)
3333 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
3334 parent_link = tags.link_to(self.get_instance_title(parent), parent_url)
3335 index_title_rendered = HTML.literal("<span> »</span>").join(
3336 [index_link, parent_link]
3337 )
3339 context = {
3340 "form": form,
3341 "index_title_rendered": index_title_rendered,
3342 "row_model_title": self.get_row_model_title(),
3343 }
3344 return self.render_to_response("create_row", context)
3346 def create_row_save_form(self, form):
3347 """
3348 This method converts the validated form data to a row model
3349 instance, and then saves the result to DB. It is called by
3350 :meth:`create_row()`.
3352 :returns: The resulting row model instance, as produced by
3353 :meth:`objectify()`.
3354 """
3355 row = self.objectify(form)
3356 session = self.Session()
3357 session.add(row)
3358 session.flush()
3359 return row
3361 def redirect_after_create_row(self, row):
3362 """
3363 Returns a redirect to the "view parent" page relative to the
3364 given newly-created row. Subclass may override as needed.
3366 This is called by :meth:`create_row()`.
3367 """
3368 parent = self.get_row_parent(row)
3369 return self.redirect(self.get_action_url("view", parent))
3371 def make_row_model_form(self, model_instance=None, **kwargs):
3372 """
3373 Create and return a form for the row model.
3375 This is called by :meth:`create_row()`.
3377 See also related methods, which are called by this one:
3379 * :meth:`get_row_model_class()`
3380 * :meth:`get_row_form_fields()`
3381 * :meth:`~wuttaweb.views.base.View.make_form()`
3382 * :meth:`configure_row_form()`
3384 :returns: :class:`~wuttaweb.forms.base.Form` instance
3385 """
3386 if "model_class" not in kwargs:
3387 model_class = self.get_row_model_class()
3388 if model_class:
3389 kwargs["model_class"] = model_class
3391 kwargs["model_instance"] = model_instance
3393 if not kwargs.get("fields"):
3394 fields = self.get_row_form_fields()
3395 if fields:
3396 kwargs["fields"] = fields
3398 form = self.make_form(**kwargs)
3399 self.configure_row_form(form)
3400 return form
3402 def get_row_form_fields(self):
3403 """
3404 Returns the initial list of field names for the row model
3405 form.
3407 This is called by :meth:`make_row_model_form()`; in the
3408 resulting :class:`~wuttaweb.forms.base.Form` instance, this
3409 becomes :attr:`~wuttaweb.forms.base.Form.fields`.
3411 This method may return ``None``, in which case the form may
3412 (try to) generate its own default list.
3414 Subclass may define :attr:`row_form_fields` for simple cases,
3415 or can override this method if needed.
3417 Note that :meth:`configure_row_form()` may be used to further
3418 modify the final field list, regardless of what this method
3419 returns. So a common pattern is to declare all "supported"
3420 fields by setting :attr:`row_form_fields` but then optionally
3421 remove or replace some in :meth:`configure_row_form()`.
3422 """
3423 if hasattr(self, "row_form_fields"):
3424 return self.row_form_fields
3425 return None
3427 def configure_row_form(self, form):
3428 """
3429 Configure the row model form.
3431 This is called by :meth:`make_row_model_form()` - for multiple
3432 CRUD views (create, view, edit, delete, possibly others).
3434 The ``form`` param will already be "complete" and ready to use
3435 as-is, but this method can further modify it based on request
3436 details etc.
3438 Subclass can override as needed, although be sure to invoke
3439 this parent method via ``super()`` if so.
3440 """
3441 form.remove("uuid")
3442 self.set_row_labels(form)
3444 ##############################
3445 # class methods
3446 ##############################
3448 @classmethod
3449 def get_model_class(cls):
3450 """
3451 Returns the model class for the view (if defined).
3453 A model class will *usually* be a SQLAlchemy mapped class,
3454 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
3456 There is no default value here, but a subclass may override by
3457 assigning :attr:`model_class`.
3459 Note that the model class is not *required* - however if you
3460 do not set the :attr:`model_class`, then you *must* set the
3461 :attr:`model_name`.
3462 """
3463 return cls.model_class
3465 @classmethod
3466 def get_model_name(cls):
3467 """
3468 Returns the model name for the view.
3470 A model name should generally be in the format of a Python
3471 class name, e.g. ``'WuttaWidget'``. (Note this is
3472 *singular*, not plural.)
3474 The default logic will call :meth:`get_model_class()` and
3475 return that class name as-is. A subclass may override by
3476 assigning :attr:`model_name`.
3477 """
3478 if hasattr(cls, "model_name"):
3479 return cls.model_name
3481 return cls.get_model_class().__name__
3483 @classmethod
3484 def get_model_name_normalized(cls):
3485 """
3486 Returns the "normalized" model name for the view.
3488 A normalized model name should generally be in the format of a
3489 Python variable name, e.g. ``'wutta_widget'``. (Note this is
3490 *singular*, not plural.)
3492 The default logic will call :meth:`get_model_name()` and
3493 simply lower-case the result. A subclass may override by
3494 assigning :attr:`model_name_normalized`.
3495 """
3496 if hasattr(cls, "model_name_normalized"):
3497 return cls.model_name_normalized
3499 return cls.get_model_name().lower()
3501 @classmethod
3502 def get_model_title(cls):
3503 """
3504 Returns the "humanized" (singular) model title for the view.
3506 The model title will be displayed to the user, so should have
3507 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
3508 (Note this is *singular*, not plural.)
3510 The default logic will call :meth:`get_model_name()` and use
3511 the result as-is. A subclass may override by assigning
3512 :attr:`model_title`.
3513 """
3514 if hasattr(cls, "model_title"):
3515 return cls.model_title
3517 if model_class := cls.get_model_class():
3518 if hasattr(model_class, "__wutta_hint__"):
3519 if model_title := model_class.__wutta_hint__.get("model_title"):
3520 return model_title
3522 return cls.get_model_name()
3524 @classmethod
3525 def get_model_title_plural(cls):
3526 """
3527 Returns the "humanized" (plural) model title for the view.
3529 The model title will be displayed to the user, so should have
3530 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
3531 (Note this is *plural*, not singular.)
3533 The default logic will call :meth:`get_model_title()` and
3534 simply add a ``'s'`` to the end. A subclass may override by
3535 assigning :attr:`model_title_plural`.
3536 """
3537 if hasattr(cls, "model_title_plural"):
3538 return cls.model_title_plural
3540 if model_class := cls.get_model_class():
3541 if hasattr(model_class, "__wutta_hint__"):
3542 if model_title_plural := model_class.__wutta_hint__.get(
3543 "model_title_plural"
3544 ):
3545 return model_title_plural
3547 model_title = cls.get_model_title()
3548 return f"{model_title}s"
3550 @classmethod
3551 def get_model_key(cls):
3552 """
3553 Returns the "model key" for the master view.
3555 This should return a tuple containing one or more "field
3556 names" corresponding to the primary key for data records.
3558 In the most simple/common scenario, where the master view
3559 represents a Wutta-based SQLAlchemy model, the return value
3560 for this method is: ``('uuid',)``
3562 Any class mapped via SQLAlchemy should be supported
3563 automatically, the keys are determined from class inspection.
3565 But there is no "sane" default for other scenarios, in which
3566 case subclass should define :attr:`model_key`. If the model
3567 key cannot be determined, raises ``AttributeError``.
3569 :returns: Tuple of field names comprising the model key.
3570 """
3571 if hasattr(cls, "model_key"):
3572 keys = cls.model_key
3573 if isinstance(keys, str):
3574 keys = [keys]
3575 return tuple(keys)
3577 model_class = cls.get_model_class()
3578 if model_class:
3579 # nb. we want the primary key but must avoid column names
3580 # in case mapped class uses different prop keys
3581 inspector = sa.inspect(model_class)
3582 keys = [col.name for col in inspector.primary_key]
3583 return tuple(
3584 prop.key
3585 for prop in inspector.column_attrs
3586 if all(col.name in keys for col in prop.columns)
3587 )
3589 raise AttributeError(f"you must define model_key for view class: {cls}")
3591 @classmethod
3592 def get_route_prefix(cls):
3593 """
3594 Returns the "route prefix" for the master view. This prefix
3595 is used for all named routes defined by the view class.
3597 For instance if route prefix is ``'widgets'`` then a view
3598 might have these routes:
3600 * ``'widgets'``
3601 * ``'widgets.create'``
3602 * ``'widgets.edit'``
3603 * ``'widgets.delete'``
3605 The default logic will call
3606 :meth:`get_model_name_normalized()` and simply add an ``'s'``
3607 to the end, making it plural. A subclass may override by
3608 assigning :attr:`route_prefix`.
3609 """
3610 if hasattr(cls, "route_prefix"):
3611 return cls.route_prefix
3613 model_name = cls.get_model_name_normalized()
3614 return f"{model_name}s"
3616 @classmethod
3617 def get_permission_prefix(cls):
3618 """
3619 Returns the "permission prefix" for the master view. This
3620 prefix is used for all permissions defined by the view class.
3622 For instance if permission prefix is ``'widgets'`` then a view
3623 might have these permissions:
3625 * ``'widgets.list'``
3626 * ``'widgets.create'``
3627 * ``'widgets.edit'``
3628 * ``'widgets.delete'``
3630 The default logic will call :meth:`get_route_prefix()` and use
3631 that value as-is. A subclass may override by assigning
3632 :attr:`permission_prefix`.
3633 """
3634 if hasattr(cls, "permission_prefix"):
3635 return cls.permission_prefix
3637 return cls.get_route_prefix()
3639 @classmethod
3640 def get_url_prefix(cls):
3641 """
3642 Returns the "URL prefix" for the master view. This prefix is
3643 used for all URLs defined by the view class.
3645 Using the same example as in :meth:`get_route_prefix()`, the
3646 URL prefix would be ``'/widgets'`` and the view would have
3647 defined routes for these URLs:
3649 * ``/widgets/``
3650 * ``/widgets/new``
3651 * ``/widgets/XXX/edit``
3652 * ``/widgets/XXX/delete``
3654 The default logic will call :meth:`get_route_prefix()` and
3655 simply add a ``'/'`` to the beginning. A subclass may
3656 override by assigning :attr:`url_prefix`.
3657 """
3658 if hasattr(cls, "url_prefix"):
3659 return cls.url_prefix
3661 route_prefix = cls.get_route_prefix()
3662 return f"/{route_prefix}"
3664 @classmethod
3665 def get_instance_url_prefix(cls):
3666 """
3667 Generate the URL prefix specific to an instance for this model
3668 view. This will include model key param placeholders; it
3669 winds up looking like:
3671 * ``/widgets/{uuid}``
3672 * ``/resources/{foo}|{bar}|{baz}``
3674 The former being the most simple/common, and the latter
3675 showing what a "composite" model key looks like, with pipe
3676 symbols separating the key parts.
3677 """
3678 prefix = cls.get_url_prefix() + "/"
3679 for i, key in enumerate(cls.get_model_key()):
3680 if i:
3681 prefix += "|"
3682 prefix += f"{{{key}}}"
3683 return prefix
3685 @classmethod
3686 def get_template_prefix(cls):
3687 """
3688 Returns the "template prefix" for the master view. This
3689 prefix is used to guess which template path to render for a
3690 given view.
3692 Using the same example as in :meth:`get_url_prefix()`, the
3693 template prefix would also be ``'/widgets'`` and the templates
3694 assumed for those routes would be:
3696 * ``/widgets/index.mako``
3697 * ``/widgets/create.mako``
3698 * ``/widgets/edit.mako``
3699 * ``/widgets/delete.mako``
3701 The default logic will call :meth:`get_url_prefix()` and
3702 return that value as-is. A subclass may override by assigning
3703 :attr:`template_prefix`.
3704 """
3705 if hasattr(cls, "template_prefix"):
3706 return cls.template_prefix
3708 return cls.get_url_prefix()
3710 @classmethod
3711 def get_grid_key(cls):
3712 """
3713 Returns the (presumably) unique key to be used for the primary
3714 grid in the :meth:`index()` view. This key may also be used
3715 as the basis (key prefix) for secondary grids.
3717 This is called from :meth:`make_model_grid()`; in the
3718 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
3719 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
3721 The default logic for this method will call
3722 :meth:`get_route_prefix()` and return that value as-is. A
3723 subclass may override by assigning :attr:`grid_key`.
3724 """
3725 if hasattr(cls, "grid_key"):
3726 return cls.grid_key
3728 return cls.get_route_prefix()
3730 @classmethod
3731 def get_config_title(cls):
3732 """
3733 Returns the "config title" for the view/model.
3735 The config title is used for page title in the
3736 :meth:`configure()` view, as well as links to it. It is
3737 usually plural, e.g. ``"Wutta Widgets"`` in which case that
3738 winds up being displayed in the web app as: **Configure Wutta
3739 Widgets**
3741 The default logic will call :meth:`get_model_title_plural()`
3742 and return that as-is. A subclass may override by assigning
3743 :attr:`config_title`.
3744 """
3745 if hasattr(cls, "config_title"):
3746 return cls.config_title
3748 return cls.get_model_title_plural()
3750 @classmethod
3751 def get_row_model_class(cls):
3752 """
3753 Returns the "child row" model class for the view. Only
3754 relevant if :attr:`has_rows` is true.
3756 Default logic returns the :attr:`row_model_class` reference.
3758 :returns: Mapped class, or ``None``
3759 """
3760 return cls.row_model_class
3762 @classmethod
3763 def get_row_model_name(cls):
3764 """
3765 Returns the row model name for the view.
3767 A model name should generally be in the format of a Python
3768 class name, e.g. ``'BatchRow'``. (Note this is *singular*,
3769 not plural.)
3771 The default logic will call :meth:`get_row_model_class()` and
3772 return that class name as-is. Subclass may override by
3773 assigning :attr:`row_model_name`.
3774 """
3775 if hasattr(cls, "row_model_name"):
3776 return cls.row_model_name
3778 return cls.get_row_model_class().__name__
3780 @classmethod
3781 def get_row_model_title(cls):
3782 """
3783 Returns the "humanized" (singular) title for the row model.
3785 The model title will be displayed to the user, so should have
3786 proper grammar and capitalization, e.g. ``"Batch Row"``.
3787 (Note this is *singular*, not plural.)
3789 The default logic will call :meth:`get_row_model_name()` and
3790 use the result as-is. Subclass may override by assigning
3791 :attr:`row_model_title`.
3793 See also :meth:`get_row_model_title_plural()`.
3794 """
3795 if hasattr(cls, "row_model_title"):
3796 return cls.row_model_title
3798 return cls.get_row_model_name()
3800 @classmethod
3801 def get_row_model_title_plural(cls):
3802 """
3803 Returns the "humanized" (plural) title for the row model.
3805 The model title will be displayed to the user, so should have
3806 proper grammar and capitalization, e.g. ``"Batch Rows"``.
3807 (Note this is *plural*, not singular.)
3809 The default logic will call :meth:`get_row_model_title()` and
3810 simply add a ``'s'`` to the end. Subclass may override by
3811 assigning :attr:`row_model_title_plural`.
3812 """
3813 if hasattr(cls, "row_model_title_plural"):
3814 return cls.row_model_title_plural
3816 row_model_title = cls.get_row_model_title()
3817 return f"{row_model_title}s"
3819 ##############################
3820 # configuration
3821 ##############################
3823 @classmethod
3824 def defaults(cls, config):
3825 """
3826 Provide default Pyramid configuration for a master view.
3828 This is generally called from within the module's
3829 ``includeme()`` function, e.g.::
3831 from wuttaweb.views import MasterView
3833 class WidgetView(MasterView):
3834 model_name = 'Widget'
3836 def includeme(config):
3837 WidgetView.defaults(config)
3839 :param config: Reference to the app's
3840 :class:`pyramid:pyramid.config.Configurator` instance.
3841 """
3842 cls._defaults(config)
3844 @classmethod
3845 def _defaults(cls, config): # pylint: disable=too-many-statements
3846 wutta_config = config.registry.settings.get("wutta_config")
3847 app = wutta_config.get_app()
3849 route_prefix = cls.get_route_prefix()
3850 permission_prefix = cls.get_permission_prefix()
3851 url_prefix = cls.get_url_prefix()
3852 model_title = cls.get_model_title()
3853 model_title_plural = cls.get_model_title_plural()
3855 # add to master view registry
3856 config.add_wutta_master_view(cls)
3858 # permission group
3859 config.add_wutta_permission_group(
3860 permission_prefix, model_title_plural, overwrite=False
3861 )
3863 # index
3864 if cls.listable:
3865 config.add_route(route_prefix, f"{url_prefix}/")
3866 config.add_view(
3867 cls,
3868 attr="index",
3869 route_name=route_prefix,
3870 permission=f"{permission_prefix}.list",
3871 )
3872 config.add_wutta_permission(
3873 permission_prefix,
3874 f"{permission_prefix}.list",
3875 f"Browse / search {model_title_plural}",
3876 )
3878 # create
3879 if cls.creatable:
3880 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new")
3881 config.add_view(
3882 cls,
3883 attr="create",
3884 route_name=f"{route_prefix}.create",
3885 permission=f"{permission_prefix}.create",
3886 )
3887 config.add_wutta_permission(
3888 permission_prefix,
3889 f"{permission_prefix}.create",
3890 f"Create new {model_title}",
3891 )
3893 # edit
3894 if cls.editable:
3895 instance_url_prefix = cls.get_instance_url_prefix()
3896 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit")
3897 config.add_view(
3898 cls,
3899 attr="edit",
3900 route_name=f"{route_prefix}.edit",
3901 permission=f"{permission_prefix}.edit",
3902 )
3903 config.add_wutta_permission(
3904 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}"
3905 )
3907 # delete
3908 if cls.deletable:
3909 instance_url_prefix = cls.get_instance_url_prefix()
3910 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete")
3911 config.add_view(
3912 cls,
3913 attr="delete",
3914 route_name=f"{route_prefix}.delete",
3915 permission=f"{permission_prefix}.delete",
3916 )
3917 config.add_wutta_permission(
3918 permission_prefix,
3919 f"{permission_prefix}.delete",
3920 f"Delete {model_title}",
3921 )
3923 # bulk delete
3924 if cls.deletable_bulk:
3925 config.add_route(
3926 f"{route_prefix}.delete_bulk",
3927 f"{url_prefix}/delete-bulk",
3928 request_method="POST",
3929 )
3930 config.add_view(
3931 cls,
3932 attr="delete_bulk",
3933 route_name=f"{route_prefix}.delete_bulk",
3934 permission=f"{permission_prefix}.delete_bulk",
3935 )
3936 config.add_wutta_permission(
3937 permission_prefix,
3938 f"{permission_prefix}.delete_bulk",
3939 f"Delete {model_title_plural} in bulk",
3940 )
3942 # autocomplete
3943 if cls.has_autocomplete:
3944 config.add_route(
3945 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete"
3946 )
3947 config.add_view(
3948 cls,
3949 attr="autocomplete",
3950 route_name=f"{route_prefix}.autocomplete",
3951 renderer="json",
3952 permission=f"{route_prefix}.list",
3953 )
3955 # download
3956 if cls.downloadable:
3957 instance_url_prefix = cls.get_instance_url_prefix()
3958 config.add_route(
3959 f"{route_prefix}.download", f"{instance_url_prefix}/download"
3960 )
3961 config.add_view(
3962 cls,
3963 attr="download",
3964 route_name=f"{route_prefix}.download",
3965 permission=f"{permission_prefix}.download",
3966 )
3967 config.add_wutta_permission(
3968 permission_prefix,
3969 f"{permission_prefix}.download",
3970 f"Download file(s) for {model_title}",
3971 )
3973 # execute
3974 if cls.executable:
3975 instance_url_prefix = cls.get_instance_url_prefix()
3976 config.add_route(
3977 f"{route_prefix}.execute",
3978 f"{instance_url_prefix}/execute",
3979 request_method="POST",
3980 )
3981 config.add_view(
3982 cls,
3983 attr="execute",
3984 route_name=f"{route_prefix}.execute",
3985 permission=f"{permission_prefix}.execute",
3986 )
3987 config.add_wutta_permission(
3988 permission_prefix,
3989 f"{permission_prefix}.execute",
3990 f"Execute {model_title}",
3991 )
3993 # configure
3994 if cls.configurable:
3995 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure")
3996 config.add_view(
3997 cls,
3998 attr="configure",
3999 route_name=f"{route_prefix}.configure",
4000 permission=f"{permission_prefix}.configure",
4001 )
4002 config.add_wutta_permission(
4003 permission_prefix,
4004 f"{permission_prefix}.configure",
4005 f"Configure {model_title_plural}",
4006 )
4008 # view
4009 # nb. always register this one last, so it does not take
4010 # priority over model-wide action routes, e.g. delete_bulk
4011 if cls.viewable:
4012 instance_url_prefix = cls.get_instance_url_prefix()
4013 config.add_route(f"{route_prefix}.view", instance_url_prefix)
4014 config.add_view(
4015 cls,
4016 attr="view",
4017 route_name=f"{route_prefix}.view",
4018 permission=f"{permission_prefix}.view",
4019 )
4020 config.add_wutta_permission(
4021 permission_prefix, f"{permission_prefix}.view", f"View {model_title}"
4022 )
4024 # version history
4025 if cls.is_versioned() and app.continuum_is_enabled():
4026 instance_url_prefix = cls.get_instance_url_prefix()
4027 config.add_wutta_permission(
4028 permission_prefix,
4029 f"{permission_prefix}.versions",
4030 f"View version history for {model_title}",
4031 )
4032 config.add_route(
4033 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/"
4034 )
4035 config.add_view(
4036 cls,
4037 attr="view_versions",
4038 route_name=f"{route_prefix}.versions",
4039 permission=f"{permission_prefix}.versions",
4040 )
4041 config.add_route(
4042 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}"
4043 )
4044 config.add_view(
4045 cls,
4046 attr="view_version",
4047 route_name=f"{route_prefix}.version",
4048 permission=f"{permission_prefix}.versions",
4049 )
4051 ##############################
4052 # row-specific routes
4053 ##############################
4055 # create row
4056 if cls.has_rows and cls.rows_creatable:
4057 config.add_wutta_permission(
4058 permission_prefix,
4059 f"{permission_prefix}.create_row",
4060 f'Create new "rows" for {model_title}',
4061 )
4062 config.add_route(
4063 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row"
4064 )
4065 config.add_view(
4066 cls,
4067 attr="create_row",
4068 route_name=f"{route_prefix}.create_row",
4069 permission=f"{permission_prefix}.create_row",
4070 )