Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / master.py: 100%
1106 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 21:14 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 21:14 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base Logic for Master Views
25"""
26# pylint: disable=too-many-lines
28import logging
29import os
30import threading
31import warnings
32from uuid import UUID
34import sqlalchemy as sa
35from sqlalchemy import orm
37from pyramid.renderers import render_to_response
38from webhelpers2.html import HTML, tags
40from wuttjamaican.util import get_class_hierarchy
41from wuttaweb.views.base import View
42from wuttaweb.util import get_form_data, render_csrf_token
43from wuttaweb.db import Session
44from wuttaweb.progress import SessionProgress
45from wuttaweb.diffs import MergeDiff, VersionDiff
48log = logging.getLogger(__name__)
51class MasterView(View): # pylint: disable=too-many-public-methods
52 """
53 Base class for "master" views.
55 Master views typically map to a table in a DB, though not always.
56 They essentially are a set of CRUD views for a certain type of
57 data record.
59 Many attributes may be overridden in subclass. For instance to
60 define :attr:`model_class`::
62 from wuttaweb.views import MasterView
63 from wuttjamaican.db.model import Person
65 class MyPersonView(MasterView):
66 model_class = Person
68 def includeme(config):
69 MyPersonView.defaults(config)
71 .. note::
73 Many of these attributes will only exist if they have been
74 explicitly defined in a subclass. There are corresponding
75 ``get_xxx()`` methods which should be used instead of accessing
76 these attributes directly.
78 .. attribute:: model_class
80 Optional reference to a :term:`data model` class. While not
81 strictly required, most views will set this to a SQLAlchemy
82 mapped class,
83 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
85 The base logic should not access this directly but instead call
86 :meth:`get_model_class()`.
88 .. attribute:: model_name
90 Optional override for the view's data model name,
91 e.g. ``'WuttaWidget'``.
93 Code should not access this directly but instead call
94 :meth:`get_model_name()`.
96 .. attribute:: model_name_normalized
98 Optional override for the view's "normalized" data model name,
99 e.g. ``'wutta_widget'``.
101 Code should not access this directly but instead call
102 :meth:`get_model_name_normalized()`.
104 .. attribute:: model_title
106 Optional override for the view's "humanized" (singular) model
107 title, e.g. ``"Wutta Widget"``.
109 Code should not access this directly but instead call
110 :meth:`get_model_title()`.
112 .. attribute:: model_title_plural
114 Optional override for the view's "humanized" (plural) model
115 title, e.g. ``"Wutta Widgets"``.
117 Code should not access this directly but instead call
118 :meth:`get_model_title_plural()`.
120 .. attribute:: model_key
122 Optional override for the view's "model key" - e.g. ``'id'``
123 (string for simple case) or composite key such as
124 ``('id_field', 'name_field')``.
126 If :attr:`model_class` is set to a SQLAlchemy mapped class, the
127 model key can be determined automatically.
129 Code should not access this directly but instead call
130 :meth:`get_model_key()`.
132 .. attribute:: grid_key
134 Optional override for the view's grid key, e.g. ``'widgets'``.
136 Code should not access this directly but instead call
137 :meth:`get_grid_key()`.
139 .. attribute:: config_title
141 Optional override for the view's "config" title, e.g. ``"Wutta
142 Widgets"`` (to be displayed as **Configure Wutta Widgets**).
144 Code should not access this directly but instead call
145 :meth:`get_config_title()`.
147 .. attribute:: route_prefix
149 Optional override for the view's route prefix,
150 e.g. ``'wutta_widgets'``.
152 Code should not access this directly but instead call
153 :meth:`get_route_prefix()`.
155 .. attribute:: permission_prefix
157 Optional override for the view's permission prefix,
158 e.g. ``'wutta_widgets'``.
160 Code should not access this directly but instead call
161 :meth:`get_permission_prefix()`.
163 .. attribute:: url_prefix
165 Optional override for the view's URL prefix,
166 e.g. ``'/widgets'``.
168 Code should not access this directly but instead call
169 :meth:`get_url_prefix()`.
171 .. attribute:: template_prefix
173 Optional override for the view's template prefix,
174 e.g. ``'/widgets'``.
176 Code should not access this directly but instead call
177 :meth:`get_template_prefix()`.
179 .. attribute:: listable
181 Boolean indicating whether the view model supports "listing" -
182 i.e. it should have an :meth:`index()` view. Default value is
183 ``True``.
185 .. attribute:: has_grid
187 Boolean indicating whether the :meth:`index()` view should
188 include a grid. Default value is ``True``.
190 .. attribute:: grid_columns
192 List of columns for the :meth:`index()` view grid.
194 This is optional; see also :meth:`get_grid_columns()`.
196 .. attribute:: checkable
198 Boolean indicating whether the grid should expose per-row
199 checkboxes. This is passed along to set
200 :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid.
202 .. method:: grid_row_class(obj, data, i)
204 This method is *not* defined on the ``MasterView`` base class;
205 however if a subclass defines it then it will be automatically
206 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
207 the main :meth:`index()` grid.
209 For more info see
210 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
212 .. attribute:: filterable
214 Boolean indicating whether the grid for the :meth:`index()`
215 view should allow filtering of data. Default is ``True``.
217 This is used by :meth:`make_model_grid()` to set the grid's
218 :attr:`~wuttaweb.grids.base.Grid.filterable` flag.
220 .. attribute:: filter_defaults
222 Optional dict of default filter state.
224 This is used by :meth:`make_model_grid()` to set the grid's
225 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
227 Only relevant if :attr:`filterable` is true.
229 .. attribute:: sortable
231 Boolean indicating whether the grid for the :meth:`index()`
232 view should allow sorting of data. Default is ``True``.
234 This is used by :meth:`make_model_grid()` to set the grid's
235 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
237 See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
239 .. attribute:: sort_on_backend
241 Boolean indicating whether the grid data for the
242 :meth:`index()` view should be sorted on the backend. Default
243 is ``True``.
245 This is used by :meth:`make_model_grid()` to set the grid's
246 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
248 Only relevant if :attr:`sortable` is true.
250 .. attribute:: sort_defaults
252 Optional list of default sorting info. Applicable for both
253 frontend and backend sorting.
255 This is used by :meth:`make_model_grid()` to set the grid's
256 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
258 Only relevant if :attr:`sortable` is true.
260 .. attribute:: paginated
262 Boolean indicating whether the grid data for the
263 :meth:`index()` view should be paginated. Default is ``True``.
265 This is used by :meth:`make_model_grid()` to set the grid's
266 :attr:`~wuttaweb.grids.base.Grid.paginated` flag.
268 .. attribute:: paginate_on_backend
270 Boolean indicating whether the grid data for the
271 :meth:`index()` view should be paginated on the backend.
272 Default is ``True``.
274 This is used by :meth:`make_model_grid()` to set the grid's
275 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
277 .. attribute:: creatable
279 Boolean indicating whether the view model supports "creating" -
280 i.e. it should have a :meth:`create()` view. Default value is
281 ``True``.
283 .. attribute:: viewable
285 Boolean indicating whether the view model supports "viewing" -
286 i.e. it should have a :meth:`view()` view. Default value is
287 ``True``.
289 .. attribute:: editable
291 Boolean indicating whether the view model supports "editing" -
292 i.e. it should have an :meth:`edit()` view. Default value is
293 ``True``.
295 See also :meth:`is_editable()`.
297 .. attribute:: deletable
299 Boolean indicating whether the view model supports "deleting" -
300 i.e. it should have a :meth:`delete()` view. Default value is
301 ``True``.
303 See also :meth:`is_deletable()`.
305 .. attribute:: deletable_bulk
307 Boolean indicating whether the view model supports "bulk
308 deleting" - i.e. it should have a :meth:`delete_bulk()` view.
309 Default value is ``False``.
311 See also :attr:`deletable_bulk_quick`.
313 .. attribute:: deletable_bulk_quick
315 Boolean indicating whether the view model supports "quick" bulk
316 deleting, i.e. the operation is reliably quick enough that it
317 should happen *synchronously* with no progress indicator.
319 Default is ``False`` in which case a progress indicator is
320 shown while the bulk deletion is performed.
322 Only relevant if :attr:`deletable_bulk` is true.
324 .. attribute:: form_fields
326 List of fields for the model form.
328 This is optional; see also :meth:`get_form_fields()`.
330 .. attribute:: has_autocomplete
332 Boolean indicating whether the view model supports
333 "autocomplete" - i.e. it should have an :meth:`autocomplete()`
334 view. Default is ``False``.
336 .. attribute:: downloadable
338 Boolean indicating whether the view model supports
339 "downloading" - i.e. it should have a :meth:`download()` view.
340 Default is ``False``.
342 .. attribute:: executable
344 Boolean indicating whether the view model supports "executing"
345 - i.e. it should have an :meth:`execute()` view. Default is
346 ``False``.
348 .. attribute:: configurable
350 Boolean indicating whether the master view supports
351 "configuring" - i.e. it should have a :meth:`configure()` view.
352 Default value is ``False``.
354 .. attribute:: version_grid_columns
356 List of columns for the :meth:`view_versions()` view grid.
358 This is optional; see also :meth:`get_version_grid_columns()`.
360 .. attribute:: mergeable
362 Boolean indicating whether the view model supports "merging two
363 records" - i.e. it should have a :meth:`merge()` view. Default
364 value is ``False``.
366 .. attribute:: merge_additive_fields
368 Optional list of fields for which values are "additive" in
369 nature when merging two records. Only relevant if
370 :attr:`mergeable` is true.
372 See also :meth:`merge_get_additive_fields()`.
374 .. attribute:: merge_coalesce_fields
376 Optional list of fields for which values should be "coalesced"
377 when merging two records. Only relevant if :attr:`mergeable`
378 is true.
380 See also :meth:`merge_get_coalesce_fields()`.
382 .. attribute:: merge_simple_fields
384 Optional list of "simple" fields when merging two records.
385 Only relevant if :attr:`mergeable` is true.
387 See also :meth:`merge_get_simple_fields()`.
389 **ROW FEATURES**
391 .. attribute:: has_rows
393 Whether the model has "child rows" which should also be
394 displayed when viewing model records. For instance when
395 viewing a :term:`batch` you want to see both the batch header
396 as well as its row data.
398 This the "master switch" for all row features; if this is turned
399 on then many other things kick in.
401 See also :attr:`row_model_class`.
403 .. attribute:: row_model_class
405 Reference to the :term:`data model` class for the child rows.
407 Subclass should define this if :attr:`has_rows` is true.
409 View logic should not access this directly but instead call
410 :meth:`get_row_model_class()`.
412 .. attribute:: row_model_name
414 Optional override for the view's row model name,
415 e.g. ``'WuttaWidget'``.
417 Code should not access this directly but instead call
418 :meth:`get_row_model_name()`.
420 .. attribute:: row_model_title
422 Optional override for the view's "humanized" (singular) row
423 model title, e.g. ``"Wutta Widget"``.
425 Code should not access this directly but instead call
426 :meth:`get_row_model_title()`.
428 .. attribute:: row_model_title_plural
430 Optional override for the view's "humanized" (plural) row model
431 title, e.g. ``"Wutta Widgets"``.
433 Code should not access this directly but instead call
434 :meth:`get_row_model_title_plural()`.
436 .. attribute:: rows_title
438 Display title for the rows grid.
440 The base logic should not access this directly but instead call
441 :meth:`get_rows_title()`.
443 .. attribute:: row_grid_columns
445 List of columns for the row grid.
447 This is optional; see also :meth:`get_row_grid_columns()`.
449 .. attribute:: rows_viewable
451 Boolean indicating whether the row model supports "viewing" -
452 i.e. the row grid should have a "View" action. Default value
453 is ``False``.
455 (For now) If you enable this, you must also override
456 :meth:`get_row_action_url_view()`.
458 .. note::
459 This eventually will cause there to be a ``row_view`` route
460 to be configured as well.
462 .. attribute:: row_form_fields
464 List of fields for the row model form.
466 This is optional; see also :meth:`get_row_form_fields()`.
468 .. attribute:: rows_creatable
470 Boolean indicating whether the row model supports "creating" -
471 i.e. a route should be defined for :meth:`create_row()`.
472 Default value is ``False``.
473 """
475 ##############################
476 # attributes
477 ##############################
479 model_class = None
481 # features
482 listable = True
483 has_grid = True
484 checkable = False
485 filterable = True
486 filter_defaults = None
487 sortable = True
488 sort_on_backend = True
489 sort_defaults = None
490 paginated = True
491 paginate_on_backend = True
492 creatable = True
493 viewable = True
494 editable = True
495 deletable = True
496 deletable_bulk = False
497 deletable_bulk_quick = False
498 has_autocomplete = False
499 downloadable = False
500 executable = False
501 execute_progress_template = None
502 configurable = False
504 # merging
505 mergeable = False
506 merge_additive_fields = None
507 merge_coalesce_fields = None
508 merge_simple_fields = None
510 # row features
511 has_rows = False
512 row_model_class = None
513 rows_filterable = True
514 rows_filter_defaults = None
515 rows_sortable = True
516 rows_sort_on_backend = True
517 rows_sort_defaults = None
518 rows_paginated = True
519 rows_paginate_on_backend = True
520 rows_viewable = False
521 rows_creatable = False
523 # current action
524 listing = False
525 creating = False
526 viewing = False
527 editing = False
528 deleting = False
529 executing = False
530 configuring = False
532 # default DB session
533 Session = Session
535 ##############################
536 # index methods
537 ##############################
539 def index(self):
540 """
541 View to "list" (filter/browse) the model data.
543 This is the "default" view for the model and is what user sees
544 when visiting the "root" path under the :attr:`url_prefix`,
545 e.g. ``/widgets/``.
547 By default, this view is included only if :attr:`listable` is
548 true.
550 The default view logic will show a "grid" (table) with the
551 model data (unless :attr:`has_grid` is false).
553 See also related methods, which are called by this one:
555 * :meth:`make_model_grid()`
556 """
557 self.listing = True
559 context = {
560 "index_url": None, # nb. avoid title link since this *is* the index
561 }
563 if self.has_grid:
564 grid = self.make_model_grid()
566 # handle "full" vs. "partial" differently
567 if self.request.GET.get("partial"):
569 # so-called 'partial' requests get just data, no html
570 context = grid.get_vue_context()
571 if grid.paginated and grid.paginate_on_backend:
572 context["pager_stats"] = grid.get_vue_pager_stats()
573 return self.json_response(context)
575 # full, not partial
577 # nb. when user asks to reset view, it is via the query
578 # string. if so we then redirect to discard that.
579 if self.request.GET.get("reset-view"):
581 # nb. we want to preserve url hash if applicable
582 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
583 return self.redirect(self.request.current_route_url(**kw))
585 context["grid"] = grid
587 return self.render_to_response("index", context)
589 ##############################
590 # create methods
591 ##############################
593 def create(self):
594 """
595 View to "create" a new model record.
597 This usually corresponds to URL like ``/widgets/new``
599 By default, this route is included only if :attr:`creatable`
600 is true.
602 The default logic calls :meth:`make_create_form()` and shows
603 that to the user. When they submit valid data, it calls
604 :meth:`save_create_form()` and then
605 :meth:`redirect_after_create()`.
606 """
607 self.creating = True
608 form = self.make_create_form()
610 if form.validate():
611 session = self.Session()
612 try:
613 result = self.save_create_form(form)
614 # nb. must always flush to ensure primary key is set
615 session.flush()
616 except Exception as err: # pylint: disable=broad-exception-caught
617 log.warning("failed to save 'create' form", exc_info=True)
618 self.request.session.flash(f"Create failed: {err}", "error")
619 else:
620 return self.redirect_after_create(result)
622 context = {"form": form}
623 return self.render_to_response("create", context)
625 def make_create_form(self):
626 """
627 Make the "create" model form. This is called by
628 :meth:`create()`.
630 Default logic calls :meth:`make_model_form()`.
632 :returns: :class:`~wuttaweb.forms.base.Form` instance
633 """
634 return self.make_model_form(cancel_url_fallback=self.get_index_url())
636 def save_create_form(self, form):
637 """
638 Save the "create" form. This is called by :meth:`create()`.
640 Default logic calls :meth:`objectify()` and then
641 :meth:`persist()`. Subclass is expected to override for
642 non-standard use cases.
644 As for return value, by default it will be whatever came back
645 from the ``objectify()`` call. In practice a subclass can
646 return whatever it likes. The value is only used as input to
647 :meth:`redirect_after_create()`.
649 :returns: Usually the model instance, but can be "anything"
650 """
651 if hasattr(self, "create_save_form"): # pragma: no cover
652 warnings.warn(
653 "MasterView.create_save_form() method name is deprecated; "
654 f"please refactor to save_create_form() instead for {self.__class__.__name__}",
655 DeprecationWarning,
656 )
657 return self.create_save_form(form)
659 obj = self.objectify(form)
660 self.persist(obj)
661 return obj
663 def redirect_after_create(self, result):
664 """
665 Must return a redirect, following successful save of the
666 "create" form. This is called by :meth:`create()`.
668 By default this redirects to the "view" page for the new
669 record.
671 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
672 """
673 return self.redirect(self.get_action_url("view", result))
675 ##############################
676 # view methods
677 ##############################
679 def view(self):
680 """
681 View to "view" a model record.
683 This usually corresponds to URL like ``/widgets/XXX``
685 By default, this route is included only if :attr:`viewable` is
686 true.
688 The default logic here is as follows:
690 First, if :attr:`has_rows` is true then
691 :meth:`make_row_model_grid()` is called.
693 If ``has_rows`` is true *and* the request has certain special
694 params relating to the grid, control may exit early. Mainly
695 this happens when a "partial" page is requested, which means
696 we just return grid data and nothing else. (Used for backend
697 sorting and pagination etc.)
699 Otherwise :meth:`make_view_form()` is called, and the template
700 is rendered.
701 """
702 self.viewing = True
703 obj = self.get_instance()
704 context = {"instance": obj}
706 if self.has_rows:
708 # always make the grid first. note that it already knows
709 # to "reset" its params when that is requested.
710 grid = self.make_row_model_grid(obj)
712 # but if user did request a "reset" then we want to
713 # redirect so the query string gets cleared out
714 if self.request.GET.get("reset-view"):
716 # nb. we want to preserve url hash if applicable
717 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
718 return self.redirect(self.request.current_route_url(**kw))
720 # so-called 'partial' requests get just the grid data
721 if self.request.params.get("partial"):
722 context = grid.get_vue_context()
723 if grid.paginated and grid.paginate_on_backend:
724 context["pager_stats"] = grid.get_vue_pager_stats()
725 return self.json_response(context)
727 context["rows_grid"] = grid
729 context["form"] = self.make_view_form(obj)
730 context["xref_buttons"] = self.get_xref_buttons(obj)
731 return self.render_to_response("view", context)
733 def make_view_form(self, obj, readonly=True):
734 """
735 Make the "view" model form. This is called by
736 :meth:`view()`.
738 Default logic calls :meth:`make_model_form()`.
740 :returns: :class:`~wuttaweb.forms.base.Form` instance
741 """
742 return self.make_model_form(obj, readonly=readonly)
744 ##############################
745 # edit methods
746 ##############################
748 def edit(self):
749 """
750 View to "edit" a model record.
752 This usually corresponds to URL like ``/widgets/XXX/edit``
754 By default, this route is included only if :attr:`editable` is
755 true.
757 The default logic calls :meth:`make_edit_form()` and shows
758 that to the user. When they submit valid data, it calls
759 :meth:`save_edit_form()` and then
760 :meth:`redirect_after_edit()`.
761 """
762 self.editing = True
763 instance = self.get_instance()
764 form = self.make_edit_form(instance)
766 if form.validate():
767 try:
768 result = self.save_edit_form(form)
769 except Exception as err: # pylint: disable=broad-exception-caught
770 log.warning("failed to save 'edit' form", exc_info=True)
771 self.request.session.flash(f"Edit failed: {err}", "error")
772 else:
773 return self.redirect_after_edit(result)
775 context = {
776 "instance": instance,
777 "form": form,
778 }
779 return self.render_to_response("edit", context)
781 def make_edit_form(self, obj):
782 """
783 Make the "edit" model form. This is called by
784 :meth:`edit()`.
786 Default logic calls :meth:`make_model_form()`.
788 :returns: :class:`~wuttaweb.forms.base.Form` instance
789 """
790 return self.make_model_form(
791 obj, cancel_url_fallback=self.get_action_url("view", obj)
792 )
794 def save_edit_form(self, form):
795 """
796 Save the "edit" form. This is called by :meth:`edit()`.
798 Default logic calls :meth:`objectify()` and then
799 :meth:`persist()`. Subclass is expected to override for
800 non-standard use cases.
802 As for return value, by default it will be whatever came back
803 from the ``objectify()`` call. In practice a subclass can
804 return whatever it likes. The value is only used as input to
805 :meth:`redirect_after_edit()`.
807 :returns: Usually the model instance, but can be "anything"
808 """
809 if hasattr(self, "edit_save_form"): # pragma: no cover
810 warnings.warn(
811 "MasterView.edit_save_form() method name is deprecated; "
812 f"please refactor to save_edit_form() instead for {self.__class__.__name__}",
813 DeprecationWarning,
814 )
815 return self.edit_save_form(form)
817 obj = self.objectify(form)
818 self.persist(obj)
819 return obj
821 def redirect_after_edit(self, result):
822 """
823 Must return a redirect, following successful save of the
824 "edit" form. This is called by :meth:`edit()`.
826 By default this redirects to the "view" page for the record.
828 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
829 """
830 return self.redirect(self.get_action_url("view", result))
832 ##############################
833 # delete methods
834 ##############################
836 def delete(self):
837 """
838 View to "delete" a model record.
840 This usually corresponds to URL like ``/widgets/XXX/delete``
842 By default, this route is included only if :attr:`deletable`
843 is true.
845 The default logic calls :meth:`make_delete_form()` and shows
846 that to the user. When they submit, it calls
847 :meth:`save_delete_form()` and then
848 :meth:`redirect_after_delete()`.
849 """
850 self.deleting = True
851 instance = self.get_instance()
853 if not self.is_deletable(instance):
854 return self.redirect(self.get_action_url("view", instance))
856 form = self.make_delete_form(instance)
858 # nb. validate() often returns empty dict here
859 if form.validate() is not False:
861 try:
862 result = self.save_delete_form( # pylint: disable=assignment-from-none
863 form
864 )
865 except Exception as err: # pylint: disable=broad-exception-caught
866 log.warning("failed to save 'delete' form", exc_info=True)
867 self.request.session.flash(f"Delete failed: {err}", "error")
868 else:
869 return self.redirect_after_delete(result)
871 context = {
872 "instance": instance,
873 "form": form,
874 }
875 return self.render_to_response("delete", context)
877 def make_delete_form(self, obj):
878 """
879 Make the "delete" model form. This is called by
880 :meth:`delete()`.
882 Default logic calls :meth:`make_model_form()` but with a
883 twist:
885 The form proper is *not* readonly; this ensures the form has a
886 submit button etc. But then all fields in the form are
887 explicitly marked readonly.
889 :returns: :class:`~wuttaweb.forms.base.Form` instance
890 """
891 # nb. this form proper is not readonly..
892 form = self.make_model_form(
893 obj,
894 cancel_url_fallback=self.get_action_url("view", obj),
895 button_label_submit="DELETE Forever",
896 button_icon_submit="trash",
897 button_type_submit="is-danger",
898 )
900 # ..but *all* fields are readonly
901 form.readonly_fields = set(form.fields)
902 return form
904 def save_delete_form(self, form):
905 """
906 Save the "delete" form. This is called by :meth:`delete()`.
908 Default logic calls :meth:`delete_instance()`. Normally
909 subclass would override that for non-standard use cases, but
910 it could also/instead override this method.
912 As for return value, by default this returns ``None``. In
913 practice a subclass can return whatever it likes. The value
914 is only used as input to :meth:`redirect_after_delete()`.
916 :returns: Usually ``None``, but can be "anything"
917 """
918 if hasattr(self, "delete_save_form"): # pragma: no cover
919 warnings.warn(
920 "MasterView.delete_save_form() method name is deprecated; "
921 f"please refactor to save_delete_form() instead for {self.__class__.__name__}",
922 DeprecationWarning,
923 )
924 self.delete_save_form(form)
925 return
927 obj = form.model_instance
928 self.delete_instance(obj)
930 def redirect_after_delete(self, result): # pylint: disable=unused-argument
931 """
932 Must return a redirect, following successful save of the
933 "delete" form. This is called by :meth:`delete()`.
935 By default this redirects back to the :meth:`index()` page.
937 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
938 """
939 return self.redirect(self.get_index_url())
941 def delete_instance(self, obj):
942 """
943 Delete the given model instance.
945 As of yet there is no default logic for this method; it will
946 raise ``NotImplementedError``. Subclass should override if
947 needed.
949 This method is called by :meth:`save_delete_form()`.
950 """
951 session = self.app.get_session(obj)
952 session.delete(obj)
954 def delete_bulk(self):
955 """
956 View to delete all records in the current :meth:`index()` grid
957 data set, i.e. those matching current query.
959 This usually corresponds to a URL like
960 ``/widgets/delete-bulk``.
962 By default, this view is included only if
963 :attr:`deletable_bulk` is true.
965 This view requires POST method. When it is finished deleting,
966 user is redirected back to :meth:`index()` view.
968 Subclass normally should not override this method, but rather
969 one of the related methods which are called (in)directly by
970 this one:
972 * :meth:`delete_bulk_action()`
973 """
975 # get current data set from grid
976 # nb. this must *not* be paginated, we need it all
977 grid = self.make_model_grid(paginated=False)
978 data = grid.get_visible_data()
980 if self.deletable_bulk_quick:
982 # delete it all and go back to listing
983 self.delete_bulk_action(data)
984 return self.redirect(self.get_index_url())
986 # start thread for delete; show progress page
987 route_prefix = self.get_route_prefix()
988 key = f"{route_prefix}.delete_bulk"
989 progress = self.make_progress(key, success_url=self.get_index_url())
990 thread = threading.Thread(
991 target=self.delete_bulk_thread,
992 args=(data,),
993 kwargs={"progress": progress},
994 )
995 thread.start()
996 return self.render_progress(progress)
998 def delete_bulk_thread( # pylint: disable=empty-docstring
999 self, query, progress=None
1000 ):
1001 """ """
1002 session = self.app.make_session()
1003 records = query.with_session(session).all()
1005 def onerror():
1006 log.warning(
1007 "failed to delete %s results for %s",
1008 len(records),
1009 self.get_model_title_plural(),
1010 exc_info=True,
1011 )
1013 self.do_thread_body(
1014 self.delete_bulk_action,
1015 (records,),
1016 {"progress": progress},
1017 onerror,
1018 session=session,
1019 progress=progress,
1020 )
1022 def delete_bulk_action(self, data, progress=None):
1023 """
1024 This method performs the actual bulk deletion, for the given
1025 data set. This is called via :meth:`delete_bulk()`.
1027 Default logic will call :meth:`is_deletable()` for every data
1028 record, and if that returns true then it calls
1029 :meth:`delete_instance()`. A progress indicator will be
1030 updated if one is provided.
1032 Subclass should override if needed.
1033 """
1034 model_title_plural = self.get_model_title_plural()
1036 def delete(obj, i): # pylint: disable=unused-argument
1037 if self.is_deletable(obj):
1038 self.delete_instance(obj)
1040 self.app.progress_loop(
1041 delete, data, progress, message=f"Deleting {model_title_plural}"
1042 )
1044 def delete_bulk_make_button(self): # pylint: disable=empty-docstring
1045 """ """
1046 route_prefix = self.get_route_prefix()
1048 label = HTML.literal(
1049 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}'
1050 )
1051 button = self.make_button(
1052 label,
1053 variant="is-danger",
1054 icon_left="trash",
1055 **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"},
1056 )
1058 form = HTML.tag(
1059 "form",
1060 method="post",
1061 action=self.request.route_url(f"{route_prefix}.delete_bulk"),
1062 ref="deleteResultsForm",
1063 class_="control",
1064 c=[
1065 render_csrf_token(self.request),
1066 button,
1067 ],
1068 )
1069 return form
1071 ##############################
1072 # version history methods
1073 ##############################
1075 @classmethod
1076 def is_versioned(cls):
1077 """
1078 Returns boolean indicating whether the model class is
1079 configured for SQLAlchemy-Continuum versioning.
1081 The default logic will directly inspect the model class, as
1082 returned by :meth:`get_model_class()`. Or you can override by
1083 setting the ``model_is_versioned`` attribute::
1085 class WidgetView(MasterView):
1086 model_class = Widget
1087 model_is_versioned = False
1089 See also :meth:`should_expose_versions()`.
1091 :returns: ``True`` if the model class is versioned; else
1092 ``False``.
1093 """
1094 if hasattr(cls, "model_is_versioned"):
1095 return cls.model_is_versioned
1097 model_class = cls.get_model_class()
1098 if hasattr(model_class, "__versioned__"):
1099 return True
1101 return False
1103 @classmethod
1104 def get_model_version_class(cls):
1105 """
1106 Returns the version class for the master model class.
1108 Should only be relevant if :meth:`is_versioned()` is true.
1109 """
1110 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1112 return continuum.version_class(cls.get_model_class())
1114 def should_expose_versions(self):
1115 """
1116 Returns boolean indicating whether versioning history should
1117 be exposed for the current user. This will return ``True``
1118 unless any of the following are ``False``:
1120 * :meth:`is_versioned()`
1121 * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()`
1122 * ``self.has_perm("versions")`` - cf. :meth:`has_perm()`
1124 :returns: ``True`` if versioning should be exposed for current
1125 user; else ``False``.
1126 """
1127 if not self.is_versioned():
1128 return False
1130 if not self.app.continuum_is_enabled():
1131 return False
1133 if not self.has_perm("versions"):
1134 return False
1136 return True
1138 def view_versions(self):
1139 """
1140 View to list version history for an object. See also
1141 :meth:`view_version()`.
1143 This usually corresponds to a URL like
1144 ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID
1145 for the record.
1147 By default, this view is included only if
1148 :meth:`is_versioned()` is true.
1150 The default view logic will show a "grid" (table) with the
1151 record's version history.
1153 See also:
1155 * :meth:`make_version_grid()`
1156 """
1157 instance = self.get_instance()
1158 instance_title = self.get_instance_title(instance)
1159 grid = self.make_version_grid(instance)
1161 # return grid data only, if partial page was requested
1162 if self.request.GET.get("partial"):
1163 context = grid.get_vue_context()
1164 if grid.paginated and grid.paginate_on_backend:
1165 context["pager_stats"] = grid.get_vue_pager_stats()
1166 return self.json_response(context)
1168 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1170 instance_link = tags.link_to(
1171 instance_title, self.get_action_url("view", instance)
1172 )
1174 index_title_rendered = HTML.literal("<span> »</span>").join(
1175 [index_link, instance_link]
1176 )
1178 return self.render_to_response(
1179 "view_versions",
1180 {
1181 "index_title_rendered": index_title_rendered,
1182 "instance": instance,
1183 "instance_title": instance_title,
1184 "instance_url": self.get_action_url("view", instance),
1185 "grid": grid,
1186 },
1187 )
1189 def make_version_grid(self, instance=None, **kwargs):
1190 """
1191 Create and return a grid for use with the
1192 :meth:`view_versions()` view.
1194 See also related methods, which are called by this one:
1196 * :meth:`get_version_grid_key()`
1197 * :meth:`get_version_grid_columns()`
1198 * :meth:`get_version_grid_data()`
1199 * :meth:`configure_version_grid()`
1201 :returns: :class:`~wuttaweb.grids.base.Grid` instance
1202 """
1203 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1205 route_prefix = self.get_route_prefix()
1206 # instance = kwargs.pop("instance", None)
1207 if not instance:
1208 instance = self.get_instance()
1210 if "key" not in kwargs:
1211 kwargs["key"] = self.get_version_grid_key()
1213 if "model_class" not in kwargs:
1214 kwargs["model_class"] = continuum.transaction_class(self.get_model_class())
1216 if "columns" not in kwargs:
1217 kwargs["columns"] = self.get_version_grid_columns()
1219 if "data" not in kwargs:
1220 kwargs["data"] = self.get_version_grid_data(instance)
1222 if "actions" not in kwargs:
1223 route = f"{route_prefix}.version"
1225 def url(txn, i): # pylint: disable=unused-argument
1226 return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
1228 kwargs["actions"] = [
1229 self.make_grid_action("view", icon="eye", url=url),
1230 ]
1232 kwargs.setdefault("paginated", True)
1234 grid = self.make_grid(**kwargs)
1235 self.configure_version_grid(grid)
1236 grid.load_settings()
1237 return grid
1239 @classmethod
1240 def get_version_grid_key(cls):
1241 """
1242 Returns the unique key to be used for the version grid, for caching
1243 sort/filter options etc.
1245 This is normally called automatically from :meth:`make_version_grid()`.
1247 :returns: Grid key as string
1248 """
1249 if hasattr(cls, "version_grid_key"):
1250 return cls.version_grid_key
1251 return f"{cls.get_route_prefix()}.history"
1253 def get_version_grid_columns(self):
1254 """
1255 Returns the default list of version grid column names, for the
1256 :meth:`view_versions()` view.
1258 This is normally called automatically by
1259 :meth:`make_version_grid()`.
1261 Subclass may define :attr:`version_grid_columns` for simple
1262 cases, or can override this method if needed.
1264 :returns: List of string column names
1265 """
1266 if hasattr(self, "version_grid_columns"):
1267 return self.version_grid_columns
1269 return [
1270 "id",
1271 "issued_at",
1272 "user",
1273 "remote_addr",
1274 "comment",
1275 ]
1277 def get_version_grid_data(self, instance):
1278 """
1279 Returns the grid data query for the :meth:`view_versions()`
1280 view.
1282 This is normally called automatically by
1283 :meth:`make_version_grid()`.
1285 Default query will locate SQLAlchemy-Continuum ``transaction``
1286 records which are associated with versions of the given model
1287 instance. See also:
1289 * :meth:`get_version_joins()`
1290 * :meth:`normalize_version_joins()`
1291 * :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
1293 :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
1294 """
1295 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1296 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1297 model_transaction_query,
1298 )
1300 model_class = self.get_model_class()
1301 txncls = continuum.transaction_class(model_class)
1302 query = model_transaction_query(instance, joins=self.normalize_version_joins())
1303 return query.order_by(txncls.issued_at.desc())
1305 def get_version_joins(self):
1306 """
1307 Override this method to declare additional version tables
1308 which should be joined when showing the overall revision
1309 history for a given model instance.
1311 Note that whatever this method returns, will be ran through
1312 :meth:`normalize_version_joins()` before being passed along to
1313 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1315 :returns: List of version joins info as described below.
1317 In the simple scenario where an "extension" table is involved,
1318 e.g. a ``UserExtension`` table::
1320 def get_version_joins(self):
1321 model = self.app.model
1322 return super().get_version_joins() + [
1323 model.UserExtension,
1324 ]
1326 In the case where a secondary table is "related" to the main
1327 model table, but not a standard extension (using the
1328 ``User.person`` relationship as example)::
1330 def get_version_joins(self):
1331 model = self.app.model
1332 return super().get_version_joins() + [
1333 (model.Person, "uuid", "person_uuid"),
1334 ]
1336 See also :meth:`get_version_grid_data()`.
1337 """
1338 return []
1340 def normalize_version_joins(self):
1341 """
1342 This method calls :meth:`get_version_joins()` and normalizes
1343 the result, which will then get passed along to
1344 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1346 Subclass should (generally) not override this, but instead
1347 override :meth:`get_version_joins()`.
1349 Each element in the return value (list) will be a 3-tuple
1350 conforming to what is needed for the query function.
1352 See also :meth:`get_version_grid_data()`.
1354 :returns: List of version joins info.
1355 """
1356 joins = []
1357 for join in self.get_version_joins():
1358 if not isinstance(join, tuple):
1359 join = (join, "uuid", "uuid")
1360 joins.append(join)
1361 return joins
1363 def configure_version_grid(self, g):
1364 """
1365 Configure the grid for the :meth:`view_versions()` view.
1367 This is called automatically by :meth:`make_version_grid()`.
1369 Default logic applies basic customization to the column labels etc.
1370 """
1371 # id
1372 g.set_label("id", "TXN ID")
1373 # g.set_link("id")
1375 # issued_at
1376 g.set_label("issued_at", "Changed")
1377 g.set_link("issued_at")
1378 g.set_sort_defaults("issued_at", "desc")
1380 # user
1381 g.set_label("user", "Changed by")
1382 g.set_link("user")
1384 # remote_addr
1385 g.set_label("remote_addr", "IP Address")
1387 # comment
1388 g.set_renderer("comment", self.render_version_comment)
1390 def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument
1391 self, txn, key, value
1392 ):
1393 return txn.meta.get("comment", "")
1395 def view_version(self): # pylint: disable=too-many-locals
1396 """
1397 View to show diff details for a particular object version.
1398 See also :meth:`view_versions()`.
1400 This usually corresponds to a URL like
1401 ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the
1402 key/ID for the record and YYY represents a
1403 SQLAlchemy-Continuum ``transaction.id``.
1405 By default, this view is included only if
1406 :meth:`is_versioned()` is true.
1408 The default view logic will display a "diff" table showing how
1409 the record's values were changed within a transaction.
1411 See also:
1413 * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`
1414 * :meth:`get_relevant_versions()`
1415 * :class:`~wuttaweb.diffs.VersionDiff`
1416 """
1417 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1418 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1419 model_transaction_query,
1420 )
1422 instance = self.get_instance()
1423 model_class = self.get_model_class()
1424 route_prefix = self.get_route_prefix()
1425 txncls = continuum.transaction_class(model_class)
1426 transactions = model_transaction_query(
1427 instance, joins=self.normalize_version_joins()
1428 )
1430 txnid = self.request.matchdict["txnid"]
1431 txn = transactions.filter(txncls.id == txnid).first()
1432 if not txn:
1433 raise self.notfound()
1435 prev_url = None
1436 older = (
1437 transactions.filter(txncls.issued_at <= txn.issued_at)
1438 .filter(txncls.id != txnid)
1439 .order_by(txncls.issued_at.desc())
1440 .first()
1441 )
1442 if older:
1443 prev_url = self.request.route_url(
1444 f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id
1445 )
1447 next_url = None
1448 newer = (
1449 transactions.filter(txncls.issued_at >= txn.issued_at)
1450 .filter(txncls.id != txnid)
1451 .order_by(txncls.issued_at)
1452 .first()
1453 )
1454 if newer:
1455 next_url = self.request.route_url(
1456 f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id
1457 )
1459 version_diffs = [
1460 VersionDiff(self.config, version)
1461 for version in self.get_relevant_versions(txn, instance)
1462 ]
1464 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1466 instance_title = self.get_instance_title(instance)
1467 instance_link = tags.link_to(
1468 instance_title, self.get_action_url("view", instance)
1469 )
1471 history_link = tags.link_to(
1472 "history",
1473 self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid),
1474 )
1476 index_title_rendered = HTML.literal("<span> »</span>").join(
1477 [index_link, instance_link, history_link]
1478 )
1480 return self.render_to_response(
1481 "view_version",
1482 {
1483 "index_title_rendered": index_title_rendered,
1484 "instance": instance,
1485 "instance_title": instance_title,
1486 "instance_url": self.get_action_url("versions", instance),
1487 "transaction": txn,
1488 "changed": self.app.render_datetime(txn.issued_at, html=True),
1489 "version_diffs": version_diffs,
1490 "show_prev_next": True,
1491 "prev_url": prev_url,
1492 "next_url": next_url,
1493 },
1494 )
1496 def get_relevant_versions(self, transaction, instance):
1497 """
1498 Should return all version records pertaining to the given
1499 model instance and transaction.
1501 This is normally called from :meth:`view_version()`.
1503 :param transaction: SQLAlchemy-Continuum ``transaction``
1504 record/instance.
1506 :param instance: Instance of the model class.
1508 :returns: List of version records.
1509 """
1510 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1512 session = self.Session()
1513 vercls = self.get_model_version_class()
1514 versions = []
1516 # first get all versions for the model instance proper
1517 versions.extend(
1518 session.query(vercls)
1519 .filter(vercls.transaction == transaction)
1520 .filter(vercls.uuid == instance.uuid)
1521 .all()
1522 )
1524 # then get all related versions, per declared joins
1525 for child_class, foreign_attr, primary_attr in self.normalize_version_joins():
1526 child_vercls = continuum.version_class(child_class)
1527 versions.extend(
1528 session.query(child_vercls)
1529 .filter(child_vercls.transaction == transaction)
1530 .filter(
1531 getattr(child_vercls, foreign_attr)
1532 == getattr(instance, primary_attr)
1533 )
1534 )
1536 return versions
1538 ##############################
1539 # autocomplete methods
1540 ##############################
1542 def autocomplete(self):
1543 """
1544 View which accepts a single ``term`` param, and returns a JSON
1545 list of autocomplete results to match.
1547 By default, this view is included only if
1548 :attr:`has_autocomplete` is true. It usually maps to a URL
1549 like ``/widgets/autocomplete``.
1551 Subclass generally does not need to override this method, but
1552 rather should override the others which this calls:
1554 * :meth:`autocomplete_data()`
1555 * :meth:`autocomplete_normalize()`
1556 """
1557 term = self.request.GET.get("term", "")
1558 if not term:
1559 return []
1561 data = self.autocomplete_data(term) # pylint: disable=assignment-from-none
1562 if not data:
1563 return []
1565 max_results = 100 # TODO
1567 results = []
1568 for obj in data[:max_results]:
1569 normal = self.autocomplete_normalize(obj)
1570 if normal:
1571 results.append(normal)
1573 return results
1575 def autocomplete_data(self, term): # pylint: disable=unused-argument
1576 """
1577 Should return the data/query for the "matching" model records,
1578 based on autocomplete search term. This is called by
1579 :meth:`autocomplete()`.
1581 Subclass must override this; default logic returns no data.
1583 :param term: String search term as-is from user, e.g. "foo bar".
1585 :returns: List of data records, or SQLAlchemy query.
1586 """
1587 return None
1589 def autocomplete_normalize(self, obj):
1590 """
1591 Should return a "normalized" version of the given model
1592 record, suitable for autocomplete JSON results. This is
1593 called by :meth:`autocomplete()`.
1595 Subclass may need to override this; default logic is
1596 simplistic but will work for basic models. It returns the
1597 "autocomplete results" dict for the object::
1599 {
1600 'value': obj.uuid,
1601 'label': str(obj),
1602 }
1604 The 2 keys shown are required; any other keys will be ignored
1605 by the view logic but may be useful on the frontend widget.
1607 :param obj: Model record/instance.
1609 :returns: Dict of "autocomplete results" format, as shown
1610 above.
1611 """
1612 return {
1613 "value": obj.uuid,
1614 "label": str(obj),
1615 }
1617 ##############################
1618 # download methods
1619 ##############################
1621 def download(self):
1622 """
1623 View to download a file associated with a model record.
1625 This usually corresponds to a URL like
1626 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
1627 for the record.
1629 By default, this view is included only if :attr:`downloadable`
1630 is true.
1632 This method will (try to) locate the file on disk, and return
1633 it as a file download response to the client.
1635 The GET request for this view may contain a ``filename`` query
1636 string parameter, which can be used to locate one of various
1637 files associated with the model record. This filename is
1638 passed to :meth:`download_path()` for locating the file.
1640 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
1642 Subclass normally should not override this method, but rather
1643 one of the related methods which are called (in)directly by
1644 this one:
1646 * :meth:`download_path()`
1647 """
1648 obj = self.get_instance()
1649 filename = self.request.GET.get("filename", None)
1651 path = self.download_path(obj, filename) # pylint: disable=assignment-from-none
1652 if not path or not os.path.exists(path):
1653 return self.notfound()
1655 return self.file_response(path)
1657 def download_path(self, obj, filename): # pylint: disable=unused-argument
1658 """
1659 Should return absolute path on disk, for the given object and
1660 filename. Result will be used to return a file response to
1661 client. This is called by :meth:`download()`.
1663 Default logic always returns ``None``; subclass must override.
1665 :param obj: Refefence to the model instance.
1667 :param filename: Name of file for which to retrieve the path.
1669 :returns: Path to file, or ``None`` if not found.
1671 Note that ``filename`` may be ``None`` in which case the "default"
1672 file path should be returned, if applicable.
1674 If this method returns ``None`` (as it does by default) then
1675 the :meth:`download()` view will return a 404 not found
1676 response.
1677 """
1678 return None
1680 ##############################
1681 # execute methods
1682 ##############################
1684 def execute(self):
1685 """
1686 View to "execute" a model record. Requires a POST request.
1688 This usually corresponds to a URL like
1689 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
1690 for the record.
1692 By default, this view is included only if :attr:`executable` is
1693 true.
1695 Probably this is a "rare" view to implement for a model. But
1696 there are two notable use cases so far, namely:
1698 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
1699 * batches (not yet implemented;
1700 cf. :doc:`rattail-manual:data/batch/index` in Rattail
1701 Manual)
1703 The general idea is to take some "irrevocable" action
1704 associated with the model record. In the case of upgrades, it
1705 is to run the upgrade script. For batches it is to "push
1706 live" the data held within the batch.
1708 Subclass normally should not override this method, but rather
1709 one of the related methods which are called (in)directly by
1710 this one:
1712 * :meth:`execute_instance()`
1713 """
1714 route_prefix = self.get_route_prefix()
1715 model_title = self.get_model_title()
1716 obj = self.get_instance()
1718 # make the progress tracker
1719 progress = self.make_progress(
1720 f"{route_prefix}.execute",
1721 success_msg=f"{model_title} was executed.",
1722 success_url=self.get_action_url("view", obj),
1723 )
1725 # start thread for execute; show progress page
1726 key = self.request.matchdict
1727 thread = threading.Thread(
1728 target=self.execute_thread,
1729 args=(key, self.request.user.uuid),
1730 kwargs={"progress": progress},
1731 )
1732 thread.start()
1733 return self.render_progress(
1734 progress,
1735 context={
1736 "instance": obj,
1737 },
1738 template=self.execute_progress_template,
1739 )
1741 def execute_instance(self, obj, user, progress=None):
1742 """
1743 Perform the actual "execution" logic for a model record.
1744 Called by :meth:`execute()`.
1746 This method does nothing by default; subclass must override.
1748 :param obj: Reference to the model instance.
1750 :param user: Reference to the
1751 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1752 is doing the execute.
1754 :param progress: Optional progress indicator factory.
1755 """
1757 def execute_thread( # pylint: disable=empty-docstring
1758 self, key, user_uuid, progress=None
1759 ):
1760 """ """
1761 model = self.app.model
1762 model_title = self.get_model_title()
1764 # nb. use new session, separate from web transaction
1765 session = self.app.make_session()
1767 # fetch model instance and user for this session
1768 obj = self.get_instance(session=session, matchdict=key)
1769 user = session.get(model.User, user_uuid)
1771 try:
1772 self.execute_instance(obj, user, progress=progress)
1774 except Exception as error: # pylint: disable=broad-exception-caught
1775 session.rollback()
1776 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1777 if progress:
1778 progress.handle_error(error)
1780 else:
1781 session.commit()
1782 if progress:
1783 progress.handle_success()
1785 finally:
1786 session.close()
1788 ##############################
1789 # configure methods
1790 ##############################
1792 def configure(self, session=None):
1793 """
1794 View for configuring aspects of the app which are pertinent to
1795 this master view and/or model.
1797 By default, this view is included only if :attr:`configurable`
1798 is true. It usually maps to a URL like ``/widgets/configure``.
1800 The expected workflow is as follows:
1802 * user navigates to Configure page
1803 * user modifies settings and clicks Save
1804 * this view then *deletes* all "known" settings
1805 * then it saves user-submitted settings
1807 That is unless ``remove_settings`` is requested, in which case
1808 settings are deleted but then none are saved. The "known"
1809 settings by default include only the "simple" settings.
1811 As a general rule, a particular setting should be configurable
1812 by (at most) one master view. Some settings may never be
1813 exposed at all. But when exposing a setting, careful thought
1814 should be given to where it logically/best belongs.
1816 Some settings are "simple" and a master view subclass need
1817 only provide their basic definitions via
1818 :meth:`configure_get_simple_settings()`. If complex settings
1819 are needed, subclass must override one or more other methods
1820 to achieve the aim(s).
1822 See also related methods, used by this one:
1824 * :meth:`configure_get_simple_settings()`
1825 * :meth:`configure_get_context()`
1826 * :meth:`configure_gather_settings()`
1827 * :meth:`configure_remove_settings()`
1828 * :meth:`configure_save_settings()`
1829 """
1830 self.configuring = True
1831 config_title = self.get_config_title()
1833 # was form submitted?
1834 if self.request.method == "POST":
1836 # maybe just remove settings
1837 if self.request.POST.get("remove_settings"):
1838 self.configure_remove_settings(session=session)
1839 self.request.session.flash(
1840 f"All settings for {config_title} have been removed.", "warning"
1841 )
1843 # reload configure page
1844 return self.redirect(self.request.current_route_url())
1846 # gather/save settings
1847 data = get_form_data(self.request)
1848 settings = self.configure_gather_settings(data)
1849 self.configure_remove_settings(session=session)
1850 self.configure_save_settings(settings, session=session)
1851 self.request.session.flash("Settings have been saved.")
1853 # reload configure page
1854 return self.redirect(self.request.url)
1856 # render configure page
1857 context = self.configure_get_context()
1858 return self.render_to_response("configure", context)
1860 def configure_get_context(
1861 self,
1862 simple_settings=None,
1863 ):
1864 """
1865 Returns the full context dict, for rendering the
1866 :meth:`configure()` page template.
1868 Default context will include ``simple_settings`` (normalized
1869 to just name/value).
1871 You may need to override this method, to add additional
1872 "complex" settings etc.
1874 :param simple_settings: Optional list of simple settings, if
1875 already initialized. Otherwise it is retrieved via
1876 :meth:`configure_get_simple_settings()`.
1878 :returns: Context dict for the page template.
1879 """
1880 context = {}
1882 # simple settings
1883 if simple_settings is None:
1884 simple_settings = self.configure_get_simple_settings()
1885 if simple_settings:
1887 # we got some, so "normalize" each definition to name/value
1888 normalized = {}
1889 for simple in simple_settings:
1891 # name
1892 name = simple["name"]
1894 # value
1895 if "value" in simple:
1896 value = simple["value"]
1897 elif simple.get("type") is bool:
1898 value = self.config.get_bool(
1899 name, default=simple.get("default", False)
1900 )
1901 else:
1902 value = self.config.get(name, default=simple.get("default"))
1904 normalized[name] = value
1906 # add to template context
1907 context["simple_settings"] = normalized
1909 return context
1911 def configure_get_simple_settings(self):
1912 """
1913 This should return a list of "simple" setting definitions for
1914 the :meth:`configure()` view, which can be handled in a more
1915 automatic way. (This is as opposed to some settings which are
1916 more complex and must be handled manually; those should not be
1917 part of this method's return value.)
1919 Basically a "simple" setting is one which can be represented
1920 by a single field/widget on the Configure page.
1922 The setting definitions returned must each be a dict of
1923 "attributes" for the setting. For instance a *very* simple
1924 setting might be::
1926 {'name': 'wutta.app_title'}
1928 The ``name`` is required, everything else is optional. Here
1929 is a more complete example::
1931 {
1932 'name': 'wutta.production',
1933 'type': bool,
1934 'default': False,
1935 'save_if_empty': False,
1936 }
1938 Note that if specified, the ``default`` should be of the same
1939 data type as defined for the setting (``bool`` in the above
1940 example). The default ``type`` is ``str``.
1942 Normally if a setting's value is effectively null, the setting
1943 is removed instead of keeping it in the DB. This behavior can
1944 be changed per-setting via the ``save_if_empty`` flag.
1946 :returns: List of setting definition dicts as described above.
1947 Note that their order does not matter since the template
1948 must explicitly define field layout etc.
1949 """
1950 return []
1952 def configure_gather_settings(
1953 self,
1954 data,
1955 simple_settings=None,
1956 ):
1957 """
1958 Collect the full set of "normalized" settings from user
1959 request, so that :meth:`configure()` can save them.
1961 Settings are gathered from the given request (e.g. POST)
1962 ``data``, but also taking into account what we know based on
1963 the simple setting definitions.
1965 Subclass may need to override this method if complex settings
1966 are required.
1968 :param data: Form data submitted via POST request.
1970 :param simple_settings: Optional list of simple settings, if
1971 already initialized. Otherwise it is retrieved via
1972 :meth:`configure_get_simple_settings()`.
1974 This method must return a list of normalized settings, similar
1975 in spirit to the definition syntax used in
1976 :meth:`configure_get_simple_settings()`. However the format
1977 returned here is minimal and contains just name/value::
1979 {
1980 'name': 'wutta.app_title',
1981 'value': 'Wutta Wutta',
1982 }
1984 Note that the ``value`` will always be a string.
1986 Also note, whereas it's possible ``data`` will not contain all
1987 known settings, the return value *should* (potentially)
1988 contain all of them.
1990 The one exception is when a simple setting has null value, by
1991 default it will not be included in the result (hence, not
1992 saved to DB) unless the setting definition has the
1993 ``save_if_empty`` flag set.
1994 """
1995 settings = []
1997 # simple settings
1998 if simple_settings is None:
1999 simple_settings = self.configure_get_simple_settings()
2000 if simple_settings:
2002 # we got some, so "normalize" each definition to name/value
2003 for simple in simple_settings:
2004 name = simple["name"]
2006 if name in data:
2007 value = data[name]
2008 elif simple.get("type") is bool:
2009 # nb. bool false will be *missing* from data
2010 value = False
2011 else:
2012 value = simple.get("default")
2014 if simple.get("type") is bool:
2015 value = str(bool(value)).lower()
2016 elif simple.get("type") is int:
2017 value = str(int(value or "0"))
2018 elif value is None:
2019 value = ""
2020 else:
2021 value = str(value)
2023 # only want to save this setting if we received a
2024 # value, or if empty values are okay to save
2025 if value or simple.get("save_if_empty"):
2026 settings.append({"name": name, "value": value})
2028 return settings
2030 def configure_remove_settings(
2031 self,
2032 simple_settings=None,
2033 session=None,
2034 ):
2035 """
2036 Remove all "known" settings from the DB; this is called by
2037 :meth:`configure()`.
2039 The point of this method is to ensure *all* "known" settings
2040 which are managed by this master view, are purged from the DB.
2042 The default logic can handle this automatically for simple
2043 settings; subclass must override for any complex settings.
2045 :param simple_settings: Optional list of simple settings, if
2046 already initialized. Otherwise it is retrieved via
2047 :meth:`configure_get_simple_settings()`.
2048 """
2049 names = []
2051 # simple settings
2052 if simple_settings is None:
2053 simple_settings = self.configure_get_simple_settings()
2054 if simple_settings:
2055 names.extend([simple["name"] for simple in simple_settings])
2057 if names:
2058 # nb. must avoid self.Session here in case that does not
2059 # point to our primary app DB
2060 session = session or self.Session()
2061 for name in names:
2062 self.app.delete_setting(session, name)
2064 def configure_save_settings(self, settings, session=None):
2065 """
2066 Save the given settings to the DB; this is called by
2067 :meth:`configure()`.
2069 This method expects a list of name/value dicts and will simply
2070 save each to the DB, with no "conversion" logic.
2072 :param settings: List of normalized setting definitions, as
2073 returned by :meth:`configure_gather_settings()`.
2074 """
2075 # nb. must avoid self.Session here in case that does not point
2076 # to our primary app DB
2077 session = session or self.Session()
2078 for setting in settings:
2079 self.app.save_setting(
2080 session, setting["name"], setting["value"], force_create=True
2081 )
2083 ##############################
2084 # grid rendering methods
2085 ##############################
2087 def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument
2088 """
2089 Custom grid value renderer for "boolean" fields.
2091 This converts a bool value to "Yes" or "No" - unless the value
2092 is ``None`` in which case this renders empty string.
2093 To use this feature for your grid::
2095 grid.set_renderer('my_bool_field', self.grid_render_bool)
2096 """
2097 if value is None:
2098 return None
2100 return "Yes" if value else "No"
2102 def grid_render_currency(self, record, key, value, scale=2):
2103 """
2104 Custom grid value renderer for "currency" fields.
2106 This expects float or decimal values, and will round the
2107 decimal as appropriate, and add the currency symbol.
2109 :param scale: Number of decimal digits to be displayed;
2110 default is 2 places.
2112 To use this feature for your grid::
2114 grid.set_renderer('my_currency_field', self.grid_render_currency)
2116 # you can also override scale
2117 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
2118 """
2120 # nb. get new value since the one provided will just be a
2121 # (json-safe) *string* if the original type was Decimal
2122 value = record[key]
2124 if value is None:
2125 return None
2127 if value < 0:
2128 fmt = f"(${{:0,.{scale}f}})"
2129 return fmt.format(0 - value)
2131 fmt = f"${{:0,.{scale}f}}"
2132 return fmt.format(value)
2134 def grid_render_datetime( # pylint: disable=empty-docstring
2135 self, record, key, value, fmt=None
2136 ):
2137 """ """
2138 warnings.warn(
2139 "MasterView.grid_render_datetime() is deprecated; "
2140 "please use app.render_datetime() directly instead",
2141 DeprecationWarning,
2142 stacklevel=2,
2143 )
2145 # nb. get new value since the one provided will just be a
2146 # (json-safe) *string* if the original type was datetime
2147 value = record[key]
2149 if value is None:
2150 return None
2152 return value.strftime(fmt or "%Y-%m-%d %I:%M:%S %p")
2154 def grid_render_enum(self, record, key, value, enum=None):
2155 """
2156 Custom grid value renderer for "enum" fields.
2158 :param enum: Enum class for the field. This should be an
2159 instance of :class:`~python:enum.Enum`.
2161 To use this feature for your grid::
2163 from enum import Enum
2165 class MyEnum(Enum):
2166 ONE = 1
2167 TWO = 2
2168 THREE = 3
2170 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
2171 """
2172 if enum:
2173 original = record[key]
2174 if original:
2175 return original.name
2177 return value
2179 def grid_render_notes( # pylint: disable=unused-argument
2180 self, record, key, value, maxlen=100
2181 ):
2182 """
2183 Custom grid value renderer for "notes" fields.
2185 If the given text ``value`` is shorter than ``maxlen``
2186 characters, it is returned as-is.
2188 But if it is longer, then it is truncated and an ellispsis is
2189 added. The resulting ``<span>`` tag is also given a ``title``
2190 attribute with the original (full) text, so that appears on
2191 mouse hover.
2193 To use this feature for your grid::
2195 grid.set_renderer('my_notes_field', self.grid_render_notes)
2197 # you can also override maxlen
2198 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
2199 """
2200 if value is None:
2201 return None
2203 if len(value) < maxlen:
2204 return value
2206 return HTML.tag("span", title=value, c=f"{value[:maxlen]}...")
2208 ##############################
2209 # support methods
2210 ##############################
2212 def get_class_hierarchy(self, topfirst=True):
2213 """
2214 Convenience to return a list of classes from which the current
2215 class inherits.
2217 This is a wrapper around
2218 :func:`wuttjamaican.util.get_class_hierarchy()`.
2219 """
2220 return get_class_hierarchy(self.__class__, topfirst=topfirst)
2222 def has_perm(self, name):
2223 """
2224 Shortcut to check if current user has the given permission.
2226 This will automatically add the :attr:`permission_prefix` to
2227 ``name`` before passing it on to
2228 :func:`~wuttaweb.subscribers.request.has_perm()`.
2230 For instance within the
2231 :class:`~wuttaweb.views.users.UserView` these give the same
2232 result::
2234 self.request.has_perm('users.edit')
2236 self.has_perm('edit')
2238 So this shortcut only applies to permissions defined for the
2239 current master view. The first example above must still be
2240 used to check for "foreign" permissions (i.e. any needing a
2241 different prefix).
2242 """
2243 permission_prefix = self.get_permission_prefix()
2244 return self.request.has_perm(f"{permission_prefix}.{name}")
2246 def has_any_perm(self, *names):
2247 """
2248 Shortcut to check if current user has any of the given
2249 permissions.
2251 This calls :meth:`has_perm()` until one returns ``True``. If
2252 none do, returns ``False``.
2253 """
2254 for name in names:
2255 if self.has_perm(name):
2256 return True
2257 return False
2259 def make_button(
2260 self,
2261 label,
2262 variant=None,
2263 primary=False,
2264 url=None,
2265 **kwargs,
2266 ):
2267 """
2268 Make and return a HTML ``<b-button>`` literal.
2270 :param label: Text label for the button.
2272 :param variant: This is the "Buefy type" (or "Oruga variant")
2273 for the button. Buefy and Oruga represent this differently
2274 but this logic expects the Buefy format
2275 (e.g. ``is-danger``) and *not* the Oruga format
2276 (e.g. ``danger``), despite the param name matching Oruga's
2277 terminology.
2279 :param type: This param is not advertised in the method
2280 signature, but if caller specifies ``type`` instead of
2281 ``variant`` it should work the same.
2283 :param primary: If neither ``variant`` nor ``type`` are
2284 specified, this flag may be used to automatically set the
2285 Buefy type to ``is-primary``.
2287 This is the preferred method where applicable, since it
2288 avoids the Buefy vs. Oruga confusion, and the
2289 implementation can change in the future.
2291 :param url: Specify this (instead of ``href``) to make the
2292 button act like a link. This will yield something like:
2293 ``<b-button tag="a" href="{url}">``
2295 :param \\**kwargs: All remaining kwargs are passed to the
2296 underlying ``HTML.tag()`` call, so will be rendered as
2297 attributes on the button tag.
2299 **NB.** You cannot specify a ``tag`` kwarg, for technical
2300 reasons.
2302 :returns: HTML literal for the button element. Will be something
2303 along the lines of:
2305 .. code-block::
2307 <b-button type="is-primary"
2308 icon-pack="fas"
2309 icon-left="hand-pointer">
2310 Click Me
2311 </b-button>
2312 """
2313 btn_kw = kwargs
2314 btn_kw.setdefault("c", label)
2315 btn_kw.setdefault("icon_pack", "fas")
2317 if "type" not in btn_kw:
2318 if variant:
2319 btn_kw["type"] = variant
2320 elif primary:
2321 btn_kw["type"] = "is-primary"
2323 if url:
2324 btn_kw["href"] = url
2326 button = HTML.tag("b-button", **btn_kw)
2328 if url:
2329 # nb. unfortunately HTML.tag() calls its first arg 'tag'
2330 # and so we can't pass a kwarg with that name...so instead
2331 # we patch that into place manually
2332 button = str(button)
2333 button = button.replace("<b-button ", '<b-button tag="a" ')
2334 button = HTML.literal(button)
2336 return button
2338 def get_xref_buttons(self, obj): # pylint: disable=unused-argument
2339 """
2340 Should return a list of "cross-reference" buttons to be shown
2341 when viewing the given object.
2343 Default logic always returns empty list; subclass can override
2344 as needed.
2346 If applicable, this method should do its own permission checks
2347 and only include the buttons current user should be allowed to
2348 see/use.
2350 See also :meth:`make_button()` - example::
2352 def get_xref_buttons(self, product):
2353 buttons = []
2354 if self.request.has_perm('external_products.view'):
2355 url = self.request.route_url('external_products.view',
2356 id=product.external_id)
2357 buttons.append(self.make_button("View External", url=url))
2358 return buttons
2359 """
2360 return []
2362 def make_progress(self, key, **kwargs):
2363 """
2364 Create and return a
2365 :class:`~wuttaweb.progress.SessionProgress` instance, with the
2366 given key.
2368 This is normally done just before calling
2369 :meth:`render_progress()`.
2370 """
2371 return SessionProgress(self.request, key, **kwargs)
2373 def render_progress(self, progress, context=None, template=None):
2374 """
2375 Render the progress page, with given template/context.
2377 When a view method needs to start a long-running operation, it
2378 first starts a thread to do the work, and then it renders the
2379 "progress" page. As the operation continues the progress page
2380 is updated. When the operation completes (or fails) the user
2381 is redirected to the final destination.
2383 TODO: should document more about how to do this..
2385 :param progress: Progress indicator instance as returned by
2386 :meth:`make_progress()`.
2388 :returns: A :term:`response` with rendered progress page.
2389 """
2390 template = template or "/progress.mako"
2391 context = context or {}
2392 context["progress"] = progress
2393 return render_to_response(template, context, request=self.request)
2395 def render_to_response(self, template, context):
2396 """
2397 Locate and render an appropriate template, with the given
2398 context, and return a :term:`response`.
2400 The specified ``template`` should be only the "base name" for
2401 the template - e.g. ``'index'`` or ``'edit'``. This method
2402 will then try to locate a suitable template file, based on
2403 values from :meth:`get_template_prefix()` and
2404 :meth:`get_fallback_templates()`.
2406 In practice this *usually* means two different template paths
2407 will be attempted, e.g. if ``template`` is ``'edit'`` and
2408 :attr:`template_prefix` is ``'/widgets'``:
2410 * ``/widgets/edit.mako``
2411 * ``/master/edit.mako``
2413 The first template found to exist will be used for rendering.
2414 It then calls
2415 :func:`pyramid:pyramid.renderers.render_to_response()` and
2416 returns the result.
2418 :param template: Base name for the template.
2420 :param context: Data dict to be used as template context.
2422 :returns: Response object containing the rendered template.
2423 """
2424 defaults = {
2425 "master": self,
2426 "route_prefix": self.get_route_prefix(),
2427 "index_title": self.get_index_title(),
2428 "index_url": self.get_index_url(),
2429 "model_title": self.get_model_title(),
2430 "model_title_plural": self.get_model_title_plural(),
2431 "config_title": self.get_config_title(),
2432 }
2434 # merge defaults + caller-provided context
2435 defaults.update(context)
2436 context = defaults
2438 # add crud flags if we have an instance
2439 if "instance" in context:
2440 instance = context["instance"]
2441 if "instance_title" not in context:
2442 context["instance_title"] = self.get_instance_title(instance)
2443 if "instance_editable" not in context:
2444 context["instance_editable"] = self.is_editable(instance)
2445 if "instance_deletable" not in context:
2446 context["instance_deletable"] = self.is_deletable(instance)
2448 # supplement context further if needed
2449 context = self.get_template_context(context)
2451 # first try the template path most specific to this view
2452 page_templates = self.get_page_templates(template)
2453 mako_path = page_templates[0]
2454 try:
2455 return render_to_response(mako_path, context, request=self.request)
2456 except IOError:
2458 # failing that, try one or more fallback templates
2459 for fallback in page_templates[1:]:
2460 try:
2461 return render_to_response(fallback, context, request=self.request)
2462 except IOError:
2463 pass
2465 # if we made it all the way here, then we found no
2466 # templates at all, in which case re-attempt the first and
2467 # let that error raise on up
2468 return render_to_response(mako_path, context, request=self.request)
2470 def get_template_context(self, context):
2471 """
2472 This method should return the "complete" context for rendering
2473 the current view template.
2475 Default logic for this method returns the given context
2476 unchanged.
2478 You may wish to override to pass extra context to the view
2479 template. Check :attr:`viewing` and similar, or
2480 ``request.current_route_name`` etc. in order to add extra
2481 context only for certain view templates.
2483 :params: context: The context dict we have so far,
2484 auto-provided by the master view logic.
2486 :returns: Final context dict for the template.
2487 """
2488 return context
2490 def get_page_templates(self, template):
2491 """
2492 Returns a list of all templates which can be attempted, to
2493 render the current page. This is called by
2494 :meth:`render_to_response()`.
2496 The list should be in order of preference, e.g. the first
2497 entry will be the most "specific" template, with subsequent
2498 entries becoming more generic.
2500 In practice this method defines the first entry but calls
2501 :meth:`get_fallback_templates()` for the rest.
2503 :param template: Base name for a template (without prefix), e.g.
2504 ``'view'``.
2506 :returns: List of template paths to be tried, based on the
2507 specified template. For instance if ``template`` is
2508 ``'view'`` this will (by default) return::
2510 [
2511 '/widgets/view.mako',
2512 '/master/view.mako',
2513 ]
2515 """
2516 template_prefix = self.get_template_prefix()
2517 page_templates = [f"{template_prefix}/{template}.mako"]
2518 page_templates.extend(self.get_fallback_templates(template))
2519 return page_templates
2521 def get_fallback_templates(self, template):
2522 """
2523 Returns a list of "fallback" template paths which may be
2524 attempted for rendering the current page. See also
2525 :meth:`get_page_templates()`.
2527 :param template: Base name for a template (without prefix), e.g.
2528 ``'view'``.
2530 :returns: List of template paths to be tried, based on the
2531 specified template. For instance if ``template`` is
2532 ``'view'`` this will (by default) return::
2534 ['/master/view.mako']
2535 """
2536 return [f"/master/{template}.mako"]
2538 def get_index_title(self):
2539 """
2540 Returns the main index title for the master view.
2542 By default this returns the value from
2543 :meth:`get_model_title_plural()`. Subclass may override as
2544 needed.
2545 """
2546 return self.get_model_title_plural()
2548 def get_index_url(self, **kwargs):
2549 """
2550 Returns the URL for master's :meth:`index()` view.
2552 NB. this returns ``None`` if :attr:`listable` is false.
2553 """
2554 if self.listable:
2555 route_prefix = self.get_route_prefix()
2556 return self.request.route_url(route_prefix, **kwargs)
2557 return None
2559 def set_labels(self, obj):
2560 """
2561 Set label overrides on a form or grid, based on what is
2562 defined by the view class and its parent class(es).
2564 This is called automatically from :meth:`configure_grid()` and
2565 :meth:`configure_form()`.
2567 This calls :meth:`collect_labels()` to find everything, then
2568 it assigns the labels using one of (based on ``obj`` type):
2570 * :func:`wuttaweb.forms.base.Form.set_label()`
2571 * :func:`wuttaweb.grids.base.Grid.set_label()`
2573 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
2574 :class:`~wuttaweb.forms.base.Form` instance.
2575 """
2576 labels = self.collect_labels()
2577 for key, label in labels.items():
2578 obj.set_label(key, label)
2580 def collect_labels(self):
2581 """
2582 Collect all labels defined by the view class and/or its parents.
2584 A master view can declare labels via class-level attribute,
2585 like so::
2587 from wuttaweb.views import MasterView
2589 class WidgetView(MasterView):
2591 labels = {
2592 'id': "Widget ID",
2593 'serial_no': "Serial Number",
2594 }
2596 All such labels, defined by any class from which the master
2597 view inherits, will be returned. However if the same label
2598 key is defined by multiple classes, the "subclass" always
2599 wins.
2601 Labels defined in this way will apply to both forms and grids.
2602 See also :meth:`set_labels()`.
2604 :returns: Dict of all labels found.
2605 """
2606 labels = {}
2607 hierarchy = self.get_class_hierarchy()
2608 for cls in hierarchy:
2609 if hasattr(cls, "labels"):
2610 labels.update(cls.labels)
2611 return labels
2613 def make_model_grid(
2614 self, session=None, **kwargs
2615 ): # pylint: disable=too-many-branches
2616 """
2617 Create and return a :class:`~wuttaweb.grids.base.Grid`
2618 instance for use with the :meth:`index()` view.
2620 See also related methods, which are called by this one:
2622 * :meth:`get_grid_key()`
2623 * :meth:`get_grid_columns()`
2624 * :meth:`get_grid_data()`
2625 * :meth:`configure_grid()`
2626 """
2627 route_prefix = self.get_route_prefix()
2629 if "key" not in kwargs:
2630 kwargs["key"] = self.get_grid_key()
2632 if "model_class" not in kwargs:
2633 model_class = self.get_model_class()
2634 if model_class:
2635 kwargs["model_class"] = model_class
2637 if "columns" not in kwargs:
2638 kwargs["columns"] = self.get_grid_columns()
2640 if "data" not in kwargs:
2641 kwargs["data"] = self.get_grid_data(
2642 columns=kwargs["columns"], session=session
2643 )
2645 if "actions" not in kwargs:
2646 actions = []
2648 # TODO: should split this off into index_get_grid_actions() ?
2650 if self.viewable and self.has_perm("view"):
2651 actions.append(
2652 self.make_grid_action(
2653 "view", icon="eye", url=self.get_action_url_view
2654 )
2655 )
2657 if self.editable and self.has_perm("edit"):
2658 actions.append(
2659 self.make_grid_action(
2660 "edit", icon="edit", url=self.get_action_url_edit
2661 )
2662 )
2664 if self.deletable and self.has_perm("delete"):
2665 actions.append(
2666 self.make_grid_action(
2667 "delete",
2668 icon="trash",
2669 url=self.get_action_url_delete,
2670 link_class="has-text-danger",
2671 )
2672 )
2674 kwargs["actions"] = actions
2676 mergeable = self.mergeable and self.has_perm("merge")
2678 if "tools" not in kwargs:
2679 tools = []
2681 # delete-bulk
2682 if self.deletable_bulk and self.has_perm("delete_bulk"):
2683 tools.append(("delete-results", self.delete_bulk_make_button()))
2685 # merge
2686 if mergeable:
2687 hidden = tags.hidden("uuids", **{":value": "checkedUUIDs"})
2688 button = self.make_button(
2689 '{{ mergeSubmitting ? "Working, please wait..." : "Merge 2 records" }}',
2690 primary=True,
2691 native_type="submit",
2692 icon_left="object-ungroup",
2693 **{":disabled": "mergeSubmitting || checkedRows.length != 2"},
2694 )
2695 csrf = render_csrf_token(self.request)
2696 html = (
2697 tags.form(
2698 self.request.route_url(f"{route_prefix}.merge"),
2699 **{"@submit": "mergeSubmitting = true"},
2700 )
2701 + csrf
2702 + hidden
2703 + button
2704 + tags.end_form()
2705 )
2706 tools.append(("merge", html))
2708 kwargs["tools"] = tools
2710 kwargs.setdefault("checkable", self.checkable or mergeable)
2711 if hasattr(self, "grid_row_class"):
2712 kwargs.setdefault("row_class", self.grid_row_class)
2713 kwargs.setdefault("filterable", self.filterable)
2714 kwargs.setdefault("filter_defaults", self.filter_defaults)
2715 kwargs.setdefault("sortable", self.sortable)
2716 kwargs.setdefault("sort_on_backend", self.sort_on_backend)
2717 kwargs.setdefault("sort_defaults", self.sort_defaults)
2718 kwargs.setdefault("paginated", self.paginated)
2719 kwargs.setdefault("paginate_on_backend", self.paginate_on_backend)
2721 grid = self.make_grid(**kwargs)
2722 self.configure_grid(grid)
2723 grid.load_settings()
2724 return grid
2726 def get_grid_columns(self):
2727 """
2728 Returns the default list of grid column names, for the
2729 :meth:`index()` view.
2731 This is called by :meth:`make_model_grid()`; in the resulting
2732 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2733 :attr:`~wuttaweb.grids.base.Grid.columns`.
2735 This method may return ``None``, in which case the grid may
2736 (try to) generate its own default list.
2738 Subclass may define :attr:`grid_columns` for simple cases, or
2739 can override this method if needed.
2741 Also note that :meth:`configure_grid()` may be used to further
2742 modify the final column set, regardless of what this method
2743 returns. So a common pattern is to declare all "supported"
2744 columns by setting :attr:`grid_columns` but then optionally
2745 remove or replace some of those within
2746 :meth:`configure_grid()`.
2747 """
2748 if hasattr(self, "grid_columns"):
2749 return self.grid_columns
2750 return None
2752 def get_grid_data( # pylint: disable=unused-argument
2753 self, columns=None, session=None
2754 ):
2755 """
2756 Returns the grid data for the :meth:`index()` view.
2758 This is called by :meth:`make_model_grid()`; in the resulting
2759 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2760 :attr:`~wuttaweb.grids.base.Grid.data`.
2762 Default logic will call :meth:`get_query()` and if successful,
2763 return the list from ``query.all()``. Otherwise returns an
2764 empty list. Subclass should override as needed.
2765 """
2766 query = self.get_query(session=session)
2767 if query:
2768 return query
2769 return []
2771 def get_query(self, session=None):
2772 """
2773 Returns the main SQLAlchemy query object for the
2774 :meth:`index()` view. This is called by
2775 :meth:`get_grid_data()`.
2777 Default logic for this method returns a "plain" query on the
2778 :attr:`model_class` if that is defined; otherwise ``None``.
2779 """
2780 model_class = self.get_model_class()
2781 if model_class:
2782 session = session or self.Session()
2783 return session.query(model_class)
2784 return None
2786 def configure_grid(self, grid):
2787 """
2788 Configure the grid for the :meth:`index()` view.
2790 This is called by :meth:`make_model_grid()`.
2792 There is minimal default logic here; subclass should override
2793 as needed. The ``grid`` param will already be "complete" and
2794 ready to use as-is, but this method can further modify it
2795 based on request details etc.
2796 """
2797 if "uuid" in grid.columns:
2798 grid.columns.remove("uuid")
2800 self.set_labels(grid)
2802 # TODO: i thought this was a good idea but if so it
2803 # needs a try/catch in case of no model class
2804 # for key in self.get_model_key():
2805 # grid.set_link(key)
2807 def get_instance(self, session=None, matchdict=None):
2808 """
2809 This should return the appropriate model instance, based on
2810 the ``matchdict`` of model keys.
2812 Normally this is called with no arguments, in which case the
2813 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
2814 will return the "current" model instance based on the request
2815 (route/params).
2817 If a ``matchdict`` is provided then that is used instead, to
2818 obtain the model keys. In the simple/common example of a
2819 "native" model in WuttaWeb, this would look like::
2821 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
2822 obj = self.get_instance(matchdict=keys)
2824 Although some models may have different, possibly composite
2825 key names to use instead. The specific keys this logic is
2826 expecting are the same as returned by :meth:`get_model_key()`.
2828 If this method is unable to locate the instance, it should
2829 raise a 404 error,
2830 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
2832 Default implementation of this method should work okay for
2833 views which define a :attr:`model_class`. For other views
2834 however it will raise ``NotImplementedError``, so subclass
2835 may need to define.
2837 .. warning::
2839 If you are defining this method for a subclass, please note
2840 this point regarding the 404 "not found" logic.
2842 It is *not* enough to simply *return* this 404 response,
2843 you must explicitly *raise* the error. For instance::
2845 def get_instance(self, **kwargs):
2847 # ..try to locate instance..
2848 obj = self.locate_instance_somehow()
2850 if not obj:
2852 # NB. THIS MAY NOT WORK AS EXPECTED
2853 #return self.notfound()
2855 # nb. should always do this in get_instance()
2856 raise self.notfound()
2858 This lets calling code not have to worry about whether or
2859 not this method might return ``None``. It can safely
2860 assume it will get back a model instance, or else a 404
2861 will kick in and control flow goes elsewhere.
2862 """
2863 model_class = self.get_model_class()
2864 if model_class:
2865 session = session or self.Session()
2866 matchdict = matchdict or self.request.matchdict
2868 def filtr(query, model_key):
2869 key = matchdict[model_key]
2870 query = query.filter(getattr(self.model_class, model_key) == key)
2871 return query
2873 query = session.query(model_class)
2875 for key in self.get_model_key():
2876 query = filtr(query, key)
2878 try:
2879 return query.one()
2880 except orm.exc.NoResultFound:
2881 pass
2883 raise self.notfound()
2885 raise NotImplementedError(
2886 "you must define get_instance() method "
2887 f" for view class: {self.__class__}"
2888 )
2890 def get_instance_title(self, instance):
2891 """
2892 Return the human-friendly "title" for the instance, to be used
2893 in the page title when viewing etc.
2895 Default logic returns the value from ``str(instance)``;
2896 subclass may override if needed.
2897 """
2898 return str(instance) or "(no title)"
2900 def get_action_route_kwargs(self, obj):
2901 """
2902 Get a dict of route kwargs for the given object.
2904 This is called from :meth:`get_action_url()` and must return
2905 kwargs suitable for use with ``request.route_url()``.
2907 In practice this should return a dict which has keys for each
2908 field from :meth:`get_model_key()` and values which come from
2909 the object.
2911 :param obj: Model instance object.
2913 :returns: The dict of route kwargs for the object.
2914 """
2915 try:
2916 return {key: obj[key] for key in self.get_model_key()}
2917 except TypeError:
2918 return {key: getattr(obj, key) for key in self.get_model_key()}
2920 def get_action_url(self, action, obj, **kwargs):
2921 """
2922 Generate an "action" URL for the given model instance.
2924 This is a shortcut which generates a route name based on
2925 :meth:`get_route_prefix()` and the ``action`` param.
2927 It calls :meth:`get_action_route_kwargs()` and then passes
2928 those along with route name to ``request.route_url()``, and
2929 returns the result.
2931 :param action: String name for the action, which corresponds
2932 to part of some named route, e.g. ``'view'`` or ``'edit'``.
2934 :param obj: Model instance object.
2936 :param \\**kwargs: Additional kwargs to be passed to
2937 ``request.route_url()``, if needed.
2938 """
2939 kw = self.get_action_route_kwargs(obj)
2940 kw.update(kwargs)
2941 route_prefix = self.get_route_prefix()
2942 return self.request.route_url(f"{route_prefix}.{action}", **kw)
2944 def get_action_url_view(self, obj, i): # pylint: disable=unused-argument
2945 """
2946 Returns the "view" grid action URL for the given object.
2948 Most typically this is like ``/widgets/XXX`` where ``XXX``
2949 represents the object's key/ID.
2951 Calls :meth:`get_action_url()` under the hood.
2952 """
2953 return self.get_action_url("view", obj)
2955 def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument
2956 """
2957 Returns the "edit" grid action URL for the given object, if
2958 applicable.
2960 Most typically this is like ``/widgets/XXX/edit`` where
2961 ``XXX`` represents the object's key/ID.
2963 This first calls :meth:`is_editable()` and if that is false,
2964 this method will return ``None``.
2966 Calls :meth:`get_action_url()` to generate the true URL.
2967 """
2968 if self.is_editable(obj):
2969 return self.get_action_url("edit", obj)
2970 return None
2972 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument
2973 """
2974 Returns the "delete" grid action URL for the given object, if
2975 applicable.
2977 Most typically this is like ``/widgets/XXX/delete`` where
2978 ``XXX`` represents the object's key/ID.
2980 This first calls :meth:`is_deletable()` and if that is false,
2981 this method will return ``None``.
2983 Calls :meth:`get_action_url()` to generate the true URL.
2984 """
2985 if self.is_deletable(obj):
2986 return self.get_action_url("delete", obj)
2987 return None
2989 def is_editable(self, obj): # pylint: disable=unused-argument
2990 """
2991 Returns a boolean indicating whether "edit" should be allowed
2992 for the given model instance (and for current user).
2994 By default this always return ``True``; subclass can override
2995 if needed.
2997 Note that the use of this method implies :attr:`editable` is
2998 true, so the method does not need to check that flag.
2999 """
3000 return True
3002 def is_deletable(self, obj): # pylint: disable=unused-argument
3003 """
3004 Returns a boolean indicating whether "delete" should be
3005 allowed for the given model instance (and for current user).
3007 By default this always return ``True``; subclass can override
3008 if needed.
3010 Note that the use of this method implies :attr:`deletable` is
3011 true, so the method does not need to check that flag.
3012 """
3013 return True
3015 def make_model_form(self, model_instance=None, fields=None, **kwargs):
3016 """
3017 Make a form for the "model" represented by this subclass.
3019 This method is normally called by all CRUD views:
3021 * :meth:`create()`
3022 * :meth:`view()`
3023 * :meth:`edit()`
3024 * :meth:`delete()`
3026 The form need not have a ``model_instance``, as in the case of
3027 :meth:`create()`. And it can be readonly as in the case of
3028 :meth:`view()` and :meth:`delete()`.
3030 If ``fields`` are not provided, :meth:`get_form_fields()` is
3031 called. Usually a subclass will define :attr:`form_fields`
3032 but it's only required if :attr:`model_class` is not set.
3034 Then :meth:`configure_form()` is called, so subclass can go
3035 crazy with that as needed.
3037 :param model_instance: Model instance/record with which to
3038 initialize the form data. Not needed for "create" forms.
3040 :param fields: Optional fields list for the form.
3042 :returns: :class:`~wuttaweb.forms.base.Form` instance
3043 """
3044 if "model_class" not in kwargs:
3045 model_class = self.get_model_class()
3046 if model_class:
3047 kwargs["model_class"] = model_class
3049 kwargs["model_instance"] = model_instance
3051 if not fields:
3052 fields = self.get_form_fields()
3053 if fields:
3054 kwargs["fields"] = fields
3056 form = self.make_form(**kwargs)
3057 self.configure_form(form)
3058 return form
3060 def get_form_fields(self):
3061 """
3062 Returns the initial list of field names for the model form.
3064 This is called by :meth:`make_model_form()`; in the resulting
3065 :class:`~wuttaweb.forms.base.Form` instance, this becomes
3066 :attr:`~wuttaweb.forms.base.Form.fields`.
3068 This method may return ``None``, in which case the form may
3069 (try to) generate its own default list.
3071 Subclass may define :attr:`form_fields` for simple cases, or
3072 can override this method if needed.
3074 Note that :meth:`configure_form()` may be used to further
3075 modify the final field list, regardless of what this method
3076 returns. So a common pattern is to declare all "supported"
3077 fields by setting :attr:`form_fields` but then optionally
3078 remove or replace some in :meth:`configure_form()`.
3079 """
3080 if hasattr(self, "form_fields"):
3081 return self.form_fields
3082 return None
3084 def configure_form(self, form):
3085 """
3086 Configure the given model form, as needed.
3088 This is called by :meth:`make_model_form()` - for multiple
3089 CRUD views (create, view, edit, delete, possibly others).
3091 The default logic here does just one thing: when "editing"
3092 (i.e. in :meth:`edit()` view) then all fields which are part
3093 of the :attr:`model_key` will be marked via
3094 :meth:`set_readonly()` so the user cannot change primary key
3095 values for a record.
3097 Subclass may override as needed. The ``form`` param will
3098 already be "complete" and ready to use as-is, but this method
3099 can further modify it based on request details etc.
3100 """
3101 form.remove("uuid")
3103 self.set_labels(form)
3105 # mark key fields as readonly to prevent edit. see also
3106 # related comments in the objectify() method
3107 if self.editing:
3108 for key in self.get_model_key():
3109 form.set_readonly(key)
3111 def objectify(self, form):
3112 """
3113 Must return a "model instance" object which reflects the
3114 validated form data.
3116 In simple cases this may just return the
3117 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
3119 When dealing with SQLAlchemy models it would return a proper
3120 mapped instance, creating it if necessary.
3122 This is called by various other form-saving methods:
3124 * :meth:`save_create_form()`
3125 * :meth:`save_edit_form()`
3126 * :meth:`create_row_save_form()`
3128 See also :meth:`persist()`.
3130 :param form: Reference to the *already validated*
3131 :class:`~wuttaweb.forms.base.Form` object. See the form's
3132 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
3133 the data.
3134 """
3136 # ColanderAlchemy schema has an objectify() method which will
3137 # return a populated model instance
3138 schema = form.get_schema()
3139 if hasattr(schema, "objectify"):
3140 return schema.objectify(form.validated, context=form.model_instance)
3142 # at this point we likely have no model class, so have to
3143 # assume we're operating on a simple dict record. we (mostly)
3144 # want to return that as-is, unless subclass overrides.
3145 data = dict(form.validated)
3147 # nb. we have a unique scenario when *editing* for a simple
3148 # dict record (no model class). we mark the key fields as
3149 # readonly in configure_form(), so they aren't part of the
3150 # data here, but we need to add them back for sake of
3151 # e.g. generating the 'view' route kwargs for redirect.
3152 if self.editing:
3153 obj = self.get_instance()
3154 for key in self.get_model_key():
3155 if key not in data:
3156 data[key] = obj[key]
3158 return data
3160 def persist(self, obj, session=None):
3161 """
3162 If applicable, this method should persist ("save") the given
3163 object's data (e.g. to DB), creating or updating it as needed.
3165 This is part of the "submit form" workflow; ``obj`` should be
3166 a model instance which already reflects the validated form
3167 data.
3169 Note that there is no default logic here, subclass must
3170 override if needed.
3172 :param obj: Model instance object as produced by
3173 :meth:`objectify()`.
3175 See also :meth:`save_create_form()` and
3176 :meth:`save_edit_form()`, which call this method.
3177 """
3178 model = self.app.model
3179 model_class = self.get_model_class()
3180 if model_class and issubclass(model_class, model.Base):
3182 # add sqlalchemy model to session
3183 session = session or self.Session()
3184 session.add(obj)
3186 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments
3187 self, func, args, kwargs, onerror=None, session=None, progress=None
3188 ):
3189 """
3190 Generic method to invoke for thread operations.
3192 :param func: Callable which performs the actual logic. This
3193 will be wrapped with a try/except statement for error
3194 handling.
3196 :param args: Tuple of positional arguments to pass to the
3197 ``func`` callable.
3199 :param kwargs: Dict of keyword arguments to pass to the
3200 ``func`` callable.
3202 :param onerror: Optional callback to invoke if ``func`` raises
3203 an error. It should not expect any arguments.
3205 :param session: Optional :term:`db session` in effect. Note
3206 that if supplied, it will be *committed* (or rolled back on
3207 error) and *closed* by this method. If you need more
3208 specialized handling, do not use this method (or don't
3209 specify the ``session``).
3211 :param progress: Optional progress factory. If supplied, this
3212 is assumed to be a
3213 :class:`~wuttaweb.progress.SessionProgress` instance, and
3214 it will be updated per success or failure of ``func``
3215 invocation.
3216 """
3217 try:
3218 func(*args, **kwargs)
3220 except Exception as error: # pylint: disable=broad-exception-caught
3221 if session:
3222 session.rollback()
3223 if onerror:
3224 onerror()
3225 else:
3226 log.warning("failed to invoke thread callable: %s", func, exc_info=True)
3227 if progress:
3228 progress.handle_error(error)
3230 else:
3231 if session:
3232 session.commit()
3233 if progress:
3234 progress.handle_success()
3236 finally:
3237 if session:
3238 session.close()
3240 ##############################
3241 # merge methods
3242 ##############################
3244 def merge(self):
3245 """
3246 View for merging two records.
3248 By default, this view is included only if :attr:`mergeable` is
3249 true. It usually maps to a URL like ``/widgets/merge``.
3251 A POST request must be used for this view; otherwise it will
3252 redirect to the :meth:`index()` view. The POST data must
3253 specify a ``uuids`` param string in
3254 ``"removing_uuid,keeping_uuid"`` format.
3256 The user is first shown a "diff" with the
3257 removing/keeping/final data records, as simple preview. They
3258 can swap removing vs. keeping if needed, and when satisfied
3259 they can "execute" the merge.
3261 See also related methods, used by this one:
3263 * :meth:`merge_validate_and_execute()`
3264 * :meth:`merge_get_data()`
3265 * :meth:`merge_get_final_data()`
3266 """
3267 if self.request.method != "POST":
3268 return self.redirect(self.get_index_url())
3270 session = self.Session()
3271 model_class = self.get_model_class()
3273 # load records to be kept/removed
3274 removing = keeping = None
3275 uuids = self.request.POST.get("uuids", "").split(",")
3276 if len(uuids) == 2:
3277 uuid1, uuid2 = uuids
3278 try:
3279 uuid1 = UUID(uuid1)
3280 uuid2 = UUID(uuid2)
3281 except ValueError:
3282 pass
3283 else:
3284 removing = session.get(model_class, uuid1)
3285 keeping = session.get(model_class, uuid2)
3287 # redirect to listing if record(s) not found
3288 if not (removing and keeping):
3289 raise self.redirect(self.get_index_url())
3291 # maybe execute merge
3292 if self.request.POST.get("execute-merge") == "true":
3293 if self.merge_validate_and_execute(removing, keeping):
3294 return self.redirect(self.get_action_url("view", keeping))
3296 removing_data = self.merge_get_data(removing)
3297 keeping_data = self.merge_get_data(keeping)
3298 diff = MergeDiff(
3299 self.config,
3300 removing_data,
3301 keeping_data,
3302 self.merge_get_final_data(removing_data, keeping_data),
3303 )
3305 context = {"removing": removing, "keeping": keeping, "diff": diff}
3306 return self.render_to_response("merge", context)
3308 def merge_get_simple_fields(self):
3309 """
3310 Return the list of "simple" fields for the merge.
3312 These "simple" fields will not have any special handling for
3313 the merge. In other words the "removing" record values will
3314 be ignored and the "keeping" record values will remain in
3315 place, without modification.
3317 If the view class defines :attr:`merge_simple_fields`, that
3318 list is returned as-is. Otherwise the list of columns from
3319 :attr:`model_class` is returned.
3321 :returns: List of simple field names.
3322 """
3323 if self.merge_simple_fields:
3324 return list(self.merge_simple_fields)
3326 mapper = sa.inspect(self.get_model_class())
3327 fields = mapper.columns.keys()
3328 return fields
3330 def merge_get_additive_fields(self):
3331 """
3332 Return the list of "additive" fields for the merge.
3334 Values from the removing/keeping record will be conceptually
3335 added together, for each of these fields.
3337 If the view class defines :attr:`merge_additive_fields`, that
3338 list is returned as-is. Otherwise an empty list is returned.
3340 :returns: List of additive field names.
3341 """
3342 if self.merge_additive_fields:
3343 return list(self.merge_additive_fields)
3344 return []
3346 def merge_get_coalesce_fields(self):
3347 """
3348 Return the list of "coalesce" fields for the merge.
3350 Values from the removing/keeping record will be conceptually
3351 "coalesced" for each of these fields.
3353 If the view class defines :attr:`merge_coalesce_fields`, that
3354 list is returned as-is. Otherwise an empty list is returned.
3356 :returns: List of coalesce field names.
3357 """
3358 if self.merge_coalesce_fields:
3359 return list(self.merge_coalesce_fields)
3360 return []
3362 def merge_get_all_fields(self):
3363 """
3364 Return the list of *all* fields for the merge.
3366 This will call each of the following methods to collect all
3367 field names, then it returns the full *sorted* list.
3369 * :meth:`merge_get_additive_fields()`
3370 * :meth:`merge_get_coalesce_fields()`
3371 * :meth:`merge_get_simple_fields()`
3373 :returns: Sorted list of all field names.
3374 """
3375 fields = set()
3376 fields.update(self.merge_get_simple_fields())
3377 fields.update(self.merge_get_additive_fields())
3378 fields.update(self.merge_get_coalesce_fields())
3379 return sorted(fields)
3381 def merge_get_data(self, obj):
3382 """
3383 Return a data dict for the given object, which will be either
3384 the "removing" or "keeping" record for the merge.
3386 By default this calls :meth:`merge_get_all_fields()` and then
3387 for each field, calls ``getattr()`` on the object. Subclass
3388 can override as needed for custom logic.
3390 :param obj: Reference to model/record instance.
3392 :returns: Data dict with all field values.
3393 """
3394 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()}
3396 def merge_get_final_data(self, removing, keeping):
3397 """
3398 Return the "final" data dict for the merge.
3400 The result will be identical to the "keeping" record, for all
3401 "simple" fields. However the "additive" and "coalesce" fields
3402 are handled specially per their nature, in which case those
3403 final values may or may not match the "keeping" record.
3405 :param removing: Data dict for the "removing" record.
3407 :param keeping: Data dict for the "keeping" record.
3409 :returns: Data dict with all "final" field values.
3411 See also:
3413 * :meth:`merge()`
3414 * :meth:`merge_get_additive_fields()`
3415 * :meth:`merge_get_coalesce_fields()`
3416 """
3417 final = dict(keeping)
3419 for field in self.merge_get_additive_fields():
3420 if isinstance(keeping[field], list):
3421 final[field] = sorted(set(removing[field] + keeping[field]))
3422 else:
3423 final[field] = removing[field] + keeping[field]
3425 for field in self.merge_get_coalesce_fields():
3426 if removing[field] is not None and keeping[field] is None:
3427 final[field] = removing[field]
3428 elif removing[field] and not keeping[field]:
3429 final[field] = removing[field]
3431 return final
3433 def merge_validate_and_execute(self, removing, keeping):
3434 """
3435 Validate and execute a merge for the two given records. It is
3436 called from :meth:`merge()`.
3438 This calls :meth:`merge_why_not()` and if that does not yield
3439 a reason to prevent the merge, then calls
3440 :meth:`merge_execute()`.
3442 If there was a reason not to merge, or if an error occurs
3443 during merge execution, a flash warning/error message is set
3444 to notify the user what happened.
3446 :param removing: Reference to the "removing" model instance/record.
3448 :param keeping: Reference to the "keeping" model instance/record.
3450 :returns: Boolean indicating whether merge execution completed
3451 successfully.
3452 """
3453 session = self.Session()
3455 # validate the merge
3456 if reason := self.merge_why_not(removing, keeping):
3457 warning = HTML.tag(
3458 "p", class_="block", c="Merge cannot proceed:"
3459 ) + HTML.tag("p", class_="block", c=reason)
3460 self.request.session.flash(warning, "warning")
3461 return False
3463 # execute the merge
3464 removed_title = str(removing)
3465 try:
3466 self.merge_execute(removing, keeping)
3467 session.flush()
3468 except Exception as err: # pylint: disable=broad-exception-caught
3469 session.rollback()
3470 log.warning("merge failed", exc_info=True)
3471 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag(
3472 "p", class_="block", c=self.app.render_error(err)
3473 )
3474 self.request.session.flash(warning, "error")
3475 return False
3477 self.request.session.flash(f"{removed_title} has been merged into {keeping}")
3478 return True
3480 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument
3481 """
3482 Can return a "reason" why the two given records should not be merged.
3484 This returns ``None`` by default, indicating the merge is
3485 allowed. Subclass can override as needed for custom logic.
3487 See also :meth:`merge_validate_and_execute()`.
3489 :param removing: Reference to the "removing" model instance/record.
3491 :param keeping: Reference to the "keeping" model instance/record.
3493 :returns: Reason not to merge (as string), or ``None``.
3494 """
3495 return None
3497 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument
3498 """
3499 Execute the actual merge for the two given objects.
3501 Default logic simply deletes the "removing" record. Subclass
3502 can override as needed for custom logic.
3504 See also :meth:`merge_validate_and_execute()`.
3506 :param removing: Reference to the "removing" model instance/record.
3508 :param keeping: Reference to the "keeping" model instance/record.
3509 """
3510 session = self.Session()
3512 # nb. default "merge" does not update kept object!
3513 session.delete(removing)
3515 ##############################
3516 # row methods
3517 ##############################
3519 def get_rows_title(self):
3520 """
3521 Returns the display title for model **rows** grid, if
3522 applicable/desired. Only relevant if :attr:`has_rows` is
3523 true.
3525 There is no default here, but subclass may override by
3526 assigning :attr:`rows_title`.
3527 """
3528 if hasattr(self, "rows_title"):
3529 return self.rows_title
3530 return self.get_row_model_title_plural()
3532 def get_row_parent(self, row):
3533 """
3534 This must return the parent object for the given child row.
3535 Only relevant if :attr:`has_rows` is true.
3537 Default logic is not implemented; subclass must override.
3538 """
3539 raise NotImplementedError
3541 def make_row_model_grid(self, obj, **kwargs):
3542 """
3543 Create and return a grid for a record's **rows** data, for use
3544 in :meth:`view()`. Only applicable if :attr:`has_rows` is
3545 true.
3547 :param obj: Current model instance for which rows data is
3548 being displayed.
3550 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
3551 rows data.
3553 See also related methods, which are called by this one:
3555 * :meth:`get_row_grid_key()`
3556 * :meth:`get_row_grid_columns()`
3557 * :meth:`get_row_grid_data()`
3558 * :meth:`configure_row_grid()`
3559 """
3560 if "key" not in kwargs:
3561 kwargs["key"] = self.get_row_grid_key()
3563 if "model_class" not in kwargs:
3564 model_class = self.get_row_model_class()
3565 if model_class:
3566 kwargs["model_class"] = model_class
3568 if "columns" not in kwargs:
3569 kwargs["columns"] = self.get_row_grid_columns()
3571 if "data" not in kwargs:
3572 kwargs["data"] = self.get_row_grid_data(obj)
3574 kwargs.setdefault("filterable", self.rows_filterable)
3575 kwargs.setdefault("filter_defaults", self.rows_filter_defaults)
3576 kwargs.setdefault("sortable", self.rows_sortable)
3577 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend)
3578 kwargs.setdefault("sort_defaults", self.rows_sort_defaults)
3579 kwargs.setdefault("paginated", self.rows_paginated)
3580 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend)
3582 if "actions" not in kwargs:
3583 actions = []
3585 if self.rows_viewable:
3586 actions.append(
3587 self.make_grid_action(
3588 "view", icon="eye", url=self.get_row_action_url_view
3589 )
3590 )
3592 if actions:
3593 kwargs["actions"] = actions
3595 grid = self.make_grid(**kwargs)
3596 self.configure_row_grid(grid)
3597 grid.load_settings()
3598 return grid
3600 def get_row_grid_key(self):
3601 """
3602 Returns the (presumably) unique key to be used for the
3603 **rows** grid in :meth:`view()`. Only relevant if
3604 :attr:`has_rows` is true.
3606 This is called from :meth:`make_row_model_grid()`; in the
3607 resulting grid, this becomes
3608 :attr:`~wuttaweb.grids.base.Grid.key`.
3610 Whereas you can define :attr:`grid_key` for the main grid, the
3611 row grid key is always generated dynamically. This
3612 incorporates the current record key (whose rows are in the
3613 grid) so that the rows grid for each record is unique.
3614 """
3615 parts = [self.get_grid_key()]
3616 for key in self.get_model_key():
3617 parts.append(str(self.request.matchdict[key]))
3618 return ".".join(parts)
3620 def get_row_grid_columns(self):
3621 """
3622 Returns the default list of column names for the **rows**
3623 grid, for use in :meth:`view()`. Only relevant if
3624 :attr:`has_rows` is true.
3626 This is called by :meth:`make_row_model_grid()`; in the
3627 resulting grid, this becomes
3628 :attr:`~wuttaweb.grids.base.Grid.columns`.
3630 This method may return ``None``, in which case the grid may
3631 (try to) generate its own default list.
3633 Subclass may define :attr:`row_grid_columns` for simple cases,
3634 or can override this method if needed.
3636 Also note that :meth:`configure_row_grid()` may be used to
3637 further modify the final column set, regardless of what this
3638 method returns. So a common pattern is to declare all
3639 "supported" columns by setting :attr:`row_grid_columns` but
3640 then optionally remove or replace some of those within
3641 :meth:`configure_row_grid()`.
3642 """
3643 if hasattr(self, "row_grid_columns"):
3644 return self.row_grid_columns
3645 return None
3647 def get_row_grid_data(self, obj):
3648 """
3649 Returns the data for the **rows** grid, for use in
3650 :meth:`view()`. Only relevant if :attr:`has_rows` is true.
3652 This is called by :meth:`make_row_model_grid()`; in the
3653 resulting grid, this becomes
3654 :attr:`~wuttaweb.grids.base.Grid.data`.
3656 Default logic not implemented; subclass must define this.
3657 """
3658 raise NotImplementedError
3660 def configure_row_grid(self, grid):
3661 """
3662 Configure the **rows** grid for use in :meth:`view()`. Only
3663 relevant if :attr:`has_rows` is true.
3665 This is called by :meth:`make_row_model_grid()`.
3667 There is minimal default logic here; subclass should override
3668 as needed. The ``grid`` param will already be "complete" and
3669 ready to use as-is, but this method can further modify it
3670 based on request details etc.
3671 """
3672 grid.remove("uuid")
3673 self.set_row_labels(grid)
3675 def set_row_labels(self, obj):
3676 """
3677 Set label overrides on a **row** form or grid, based on what
3678 is defined by the view class and its parent class(es).
3680 This is called automatically from
3681 :meth:`configure_row_grid()` and
3682 :meth:`configure_row_form()`.
3684 This calls :meth:`collect_row_labels()` to find everything,
3685 then it assigns the labels using one of (based on ``obj``
3686 type):
3688 * :func:`wuttaweb.forms.base.Form.set_label()`
3689 * :func:`wuttaweb.grids.base.Grid.set_label()`
3691 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
3692 :class:`~wuttaweb.forms.base.Form` instance.
3693 """
3694 labels = self.collect_row_labels()
3695 for key, label in labels.items():
3696 obj.set_label(key, label)
3698 def collect_row_labels(self):
3699 """
3700 Collect all **row** labels defined within the view class
3701 hierarchy.
3703 This is called by :meth:`set_row_labels()`.
3705 :returns: Dict of all labels found.
3706 """
3707 labels = {}
3708 hierarchy = self.get_class_hierarchy()
3709 for cls in hierarchy:
3710 if hasattr(cls, "row_labels"):
3711 labels.update(cls.row_labels)
3712 return labels
3714 def get_row_action_url_view(self, row, i):
3715 """
3716 Must return the "view" action url for the given row object.
3718 Only relevant if :attr:`rows_viewable` is true.
3720 There is no default logic; subclass must override if needed.
3721 """
3722 raise NotImplementedError
3724 def create_row(self):
3725 """
3726 View to create a new "child row" record.
3728 This usually corresponds to a URL like ``/widgets/XXX/new-row``.
3730 By default, this view is included only if
3731 :attr:`rows_creatable` is true.
3733 The default "create row" view logic will show a form with
3734 field widgets, allowing user to submit new values which are
3735 then persisted to the DB (assuming typical SQLAlchemy model).
3737 Subclass normally should not override this method, but rather
3738 one of the related methods which are called (in)directly by
3739 this one:
3741 * :meth:`make_row_model_form()`
3742 * :meth:`configure_row_form()`
3743 * :meth:`create_row_save_form()`
3744 * :meth:`redirect_after_create_row()`
3745 """
3746 self.creating = True
3747 parent = self.get_instance()
3748 parent_url = self.get_action_url("view", parent)
3750 form = self.make_row_model_form(cancel_url_fallback=parent_url)
3751 if form.validate():
3752 result = self.create_row_save_form(form)
3753 return self.redirect_after_create_row(result)
3755 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
3756 parent_link = tags.link_to(self.get_instance_title(parent), parent_url)
3757 index_title_rendered = HTML.literal("<span> »</span>").join(
3758 [index_link, parent_link]
3759 )
3761 context = {
3762 "form": form,
3763 "index_title_rendered": index_title_rendered,
3764 "row_model_title": self.get_row_model_title(),
3765 }
3766 return self.render_to_response("create_row", context)
3768 def create_row_save_form(self, form):
3769 """
3770 This method converts the validated form data to a row model
3771 instance, and then saves the result to DB. It is called by
3772 :meth:`create_row()`.
3774 :returns: The resulting row model instance, as produced by
3775 :meth:`objectify()`.
3776 """
3777 row = self.objectify(form)
3778 session = self.Session()
3779 session.add(row)
3780 session.flush()
3781 return row
3783 def redirect_after_create_row(self, row):
3784 """
3785 Returns a redirect to the "view parent" page relative to the
3786 given newly-created row. Subclass may override as needed.
3788 This is called by :meth:`create_row()`.
3789 """
3790 parent = self.get_row_parent(row)
3791 return self.redirect(self.get_action_url("view", parent))
3793 def make_row_model_form(self, model_instance=None, **kwargs):
3794 """
3795 Create and return a form for the row model.
3797 This is called by :meth:`create_row()`.
3799 See also related methods, which are called by this one:
3801 * :meth:`get_row_model_class()`
3802 * :meth:`get_row_form_fields()`
3803 * :meth:`~wuttaweb.views.base.View.make_form()`
3804 * :meth:`configure_row_form()`
3806 :returns: :class:`~wuttaweb.forms.base.Form` instance
3807 """
3808 if "model_class" not in kwargs:
3809 model_class = self.get_row_model_class()
3810 if model_class:
3811 kwargs["model_class"] = model_class
3813 kwargs["model_instance"] = model_instance
3815 if not kwargs.get("fields"):
3816 fields = self.get_row_form_fields()
3817 if fields:
3818 kwargs["fields"] = fields
3820 form = self.make_form(**kwargs)
3821 self.configure_row_form(form)
3822 return form
3824 def get_row_form_fields(self):
3825 """
3826 Returns the initial list of field names for the row model
3827 form.
3829 This is called by :meth:`make_row_model_form()`; in the
3830 resulting :class:`~wuttaweb.forms.base.Form` instance, this
3831 becomes :attr:`~wuttaweb.forms.base.Form.fields`.
3833 This method may return ``None``, in which case the form may
3834 (try to) generate its own default list.
3836 Subclass may define :attr:`row_form_fields` for simple cases,
3837 or can override this method if needed.
3839 Note that :meth:`configure_row_form()` may be used to further
3840 modify the final field list, regardless of what this method
3841 returns. So a common pattern is to declare all "supported"
3842 fields by setting :attr:`row_form_fields` but then optionally
3843 remove or replace some in :meth:`configure_row_form()`.
3844 """
3845 if hasattr(self, "row_form_fields"):
3846 return self.row_form_fields
3847 return None
3849 def configure_row_form(self, form):
3850 """
3851 Configure the row model form.
3853 This is called by :meth:`make_row_model_form()` - for multiple
3854 CRUD views (create, view, edit, delete, possibly others).
3856 The ``form`` param will already be "complete" and ready to use
3857 as-is, but this method can further modify it based on request
3858 details etc.
3860 Subclass can override as needed, although be sure to invoke
3861 this parent method via ``super()`` if so.
3862 """
3863 form.remove("uuid")
3864 self.set_row_labels(form)
3866 ##############################
3867 # class methods
3868 ##############################
3870 @classmethod
3871 def get_model_class(cls):
3872 """
3873 Returns the model class for the view (if defined).
3875 A model class will *usually* be a SQLAlchemy mapped class,
3876 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
3878 There is no default value here, but a subclass may override by
3879 assigning :attr:`model_class`.
3881 Note that the model class is not *required* - however if you
3882 do not set the :attr:`model_class`, then you *must* set the
3883 :attr:`model_name`.
3884 """
3885 return cls.model_class
3887 @classmethod
3888 def get_model_name(cls):
3889 """
3890 Returns the model name for the view.
3892 A model name should generally be in the format of a Python
3893 class name, e.g. ``'WuttaWidget'``. (Note this is
3894 *singular*, not plural.)
3896 The default logic will call :meth:`get_model_class()` and
3897 return that class name as-is. A subclass may override by
3898 assigning :attr:`model_name`.
3899 """
3900 if hasattr(cls, "model_name"):
3901 return cls.model_name
3903 return cls.get_model_class().__name__
3905 @classmethod
3906 def get_model_name_normalized(cls):
3907 """
3908 Returns the "normalized" model name for the view.
3910 A normalized model name should generally be in the format of a
3911 Python variable name, e.g. ``'wutta_widget'``. (Note this is
3912 *singular*, not plural.)
3914 The default logic will call :meth:`get_model_name()` and
3915 simply lower-case the result. A subclass may override by
3916 assigning :attr:`model_name_normalized`.
3917 """
3918 if hasattr(cls, "model_name_normalized"):
3919 return cls.model_name_normalized
3921 return cls.get_model_name().lower()
3923 @classmethod
3924 def get_model_title(cls):
3925 """
3926 Returns the "humanized" (singular) model title for the view.
3928 The model title will be displayed to the user, so should have
3929 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
3930 (Note this is *singular*, not plural.)
3932 The default logic will call :meth:`get_model_name()` and use
3933 the result as-is. A subclass may override by assigning
3934 :attr:`model_title`.
3935 """
3936 if hasattr(cls, "model_title"):
3937 return cls.model_title
3939 if model_class := cls.get_model_class():
3940 if hasattr(model_class, "__wutta_hint__"):
3941 if model_title := model_class.__wutta_hint__.get("model_title"):
3942 return model_title
3944 return cls.get_model_name()
3946 @classmethod
3947 def get_model_title_plural(cls):
3948 """
3949 Returns the "humanized" (plural) model title for the view.
3951 The model title will be displayed to the user, so should have
3952 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
3953 (Note this is *plural*, not singular.)
3955 The default logic will call :meth:`get_model_title()` and
3956 simply add a ``'s'`` to the end. A subclass may override by
3957 assigning :attr:`model_title_plural`.
3958 """
3959 if hasattr(cls, "model_title_plural"):
3960 return cls.model_title_plural
3962 if model_class := cls.get_model_class():
3963 if hasattr(model_class, "__wutta_hint__"):
3964 if model_title_plural := model_class.__wutta_hint__.get(
3965 "model_title_plural"
3966 ):
3967 return model_title_plural
3969 model_title = cls.get_model_title()
3970 return f"{model_title}s"
3972 @classmethod
3973 def get_model_key(cls):
3974 """
3975 Returns the "model key" for the master view.
3977 This should return a tuple containing one or more "field
3978 names" corresponding to the primary key for data records.
3980 In the most simple/common scenario, where the master view
3981 represents a Wutta-based SQLAlchemy model, the return value
3982 for this method is: ``('uuid',)``
3984 Any class mapped via SQLAlchemy should be supported
3985 automatically, the keys are determined from class inspection.
3987 But there is no "sane" default for other scenarios, in which
3988 case subclass should define :attr:`model_key`. If the model
3989 key cannot be determined, raises ``AttributeError``.
3991 :returns: Tuple of field names comprising the model key.
3992 """
3993 if hasattr(cls, "model_key"):
3994 keys = cls.model_key
3995 if isinstance(keys, str):
3996 keys = [keys]
3997 return tuple(keys)
3999 model_class = cls.get_model_class()
4000 if model_class:
4001 # nb. we want the primary key but must avoid column names
4002 # in case mapped class uses different prop keys
4003 inspector = sa.inspect(model_class)
4004 keys = [col.name for col in inspector.primary_key]
4005 return tuple(
4006 prop.key
4007 for prop in inspector.column_attrs
4008 if all(col.name in keys for col in prop.columns)
4009 )
4011 raise AttributeError(f"you must define model_key for view class: {cls}")
4013 @classmethod
4014 def get_route_prefix(cls):
4015 """
4016 Returns the "route prefix" for the master view. This prefix
4017 is used for all named routes defined by the view class.
4019 For instance if route prefix is ``'widgets'`` then a view
4020 might have these routes:
4022 * ``'widgets'``
4023 * ``'widgets.create'``
4024 * ``'widgets.edit'``
4025 * ``'widgets.delete'``
4027 The default logic will call
4028 :meth:`get_model_name_normalized()` and simply add an ``'s'``
4029 to the end, making it plural. A subclass may override by
4030 assigning :attr:`route_prefix`.
4031 """
4032 if hasattr(cls, "route_prefix"):
4033 return cls.route_prefix
4035 model_name = cls.get_model_name_normalized()
4036 return f"{model_name}s"
4038 @classmethod
4039 def get_permission_prefix(cls):
4040 """
4041 Returns the "permission prefix" for the master view. This
4042 prefix is used for all permissions defined by the view class.
4044 For instance if permission prefix is ``'widgets'`` then a view
4045 might have these permissions:
4047 * ``'widgets.list'``
4048 * ``'widgets.create'``
4049 * ``'widgets.edit'``
4050 * ``'widgets.delete'``
4052 The default logic will call :meth:`get_route_prefix()` and use
4053 that value as-is. A subclass may override by assigning
4054 :attr:`permission_prefix`.
4055 """
4056 if hasattr(cls, "permission_prefix"):
4057 return cls.permission_prefix
4059 return cls.get_route_prefix()
4061 @classmethod
4062 def get_url_prefix(cls):
4063 """
4064 Returns the "URL prefix" for the master view. This prefix is
4065 used for all URLs defined by the view class.
4067 Using the same example as in :meth:`get_route_prefix()`, the
4068 URL prefix would be ``'/widgets'`` and the view would have
4069 defined routes for these URLs:
4071 * ``/widgets/``
4072 * ``/widgets/new``
4073 * ``/widgets/XXX/edit``
4074 * ``/widgets/XXX/delete``
4076 The default logic will call :meth:`get_route_prefix()` and
4077 simply add a ``'/'`` to the beginning. A subclass may
4078 override by assigning :attr:`url_prefix`.
4079 """
4080 if hasattr(cls, "url_prefix"):
4081 return cls.url_prefix
4083 route_prefix = cls.get_route_prefix()
4084 return f"/{route_prefix}"
4086 @classmethod
4087 def get_instance_url_prefix(cls):
4088 """
4089 Generate the URL prefix specific to an instance for this model
4090 view. This will include model key param placeholders; it
4091 winds up looking like:
4093 * ``/widgets/{uuid}``
4094 * ``/resources/{foo}|{bar}|{baz}``
4096 The former being the most simple/common, and the latter
4097 showing what a "composite" model key looks like, with pipe
4098 symbols separating the key parts.
4099 """
4100 prefix = cls.get_url_prefix() + "/"
4101 for i, key in enumerate(cls.get_model_key()):
4102 if i:
4103 prefix += "|"
4104 prefix += f"{{{key}}}"
4105 return prefix
4107 @classmethod
4108 def get_template_prefix(cls):
4109 """
4110 Returns the "template prefix" for the master view. This
4111 prefix is used to guess which template path to render for a
4112 given view.
4114 Using the same example as in :meth:`get_url_prefix()`, the
4115 template prefix would also be ``'/widgets'`` and the templates
4116 assumed for those routes would be:
4118 * ``/widgets/index.mako``
4119 * ``/widgets/create.mako``
4120 * ``/widgets/edit.mako``
4121 * ``/widgets/delete.mako``
4123 The default logic will call :meth:`get_url_prefix()` and
4124 return that value as-is. A subclass may override by assigning
4125 :attr:`template_prefix`.
4126 """
4127 if hasattr(cls, "template_prefix"):
4128 return cls.template_prefix
4130 return cls.get_url_prefix()
4132 @classmethod
4133 def get_grid_key(cls):
4134 """
4135 Returns the (presumably) unique key to be used for the primary
4136 grid in the :meth:`index()` view. This key may also be used
4137 as the basis (key prefix) for secondary grids.
4139 This is called from :meth:`make_model_grid()`; in the
4140 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
4141 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
4143 The default logic for this method will call
4144 :meth:`get_route_prefix()` and return that value as-is. A
4145 subclass may override by assigning :attr:`grid_key`.
4146 """
4147 if hasattr(cls, "grid_key"):
4148 return cls.grid_key
4150 return cls.get_route_prefix()
4152 @classmethod
4153 def get_config_title(cls):
4154 """
4155 Returns the "config title" for the view/model.
4157 The config title is used for page title in the
4158 :meth:`configure()` view, as well as links to it. It is
4159 usually plural, e.g. ``"Wutta Widgets"`` in which case that
4160 winds up being displayed in the web app as: **Configure Wutta
4161 Widgets**
4163 The default logic will call :meth:`get_model_title_plural()`
4164 and return that as-is. A subclass may override by assigning
4165 :attr:`config_title`.
4166 """
4167 if hasattr(cls, "config_title"):
4168 return cls.config_title
4170 return cls.get_model_title_plural()
4172 @classmethod
4173 def get_row_model_class(cls):
4174 """
4175 Returns the "child row" model class for the view. Only
4176 relevant if :attr:`has_rows` is true.
4178 Default logic returns the :attr:`row_model_class` reference.
4180 :returns: Mapped class, or ``None``
4181 """
4182 return cls.row_model_class
4184 @classmethod
4185 def get_row_model_name(cls):
4186 """
4187 Returns the row model name for the view.
4189 A model name should generally be in the format of a Python
4190 class name, e.g. ``'BatchRow'``. (Note this is *singular*,
4191 not plural.)
4193 The default logic will call :meth:`get_row_model_class()` and
4194 return that class name as-is. Subclass may override by
4195 assigning :attr:`row_model_name`.
4196 """
4197 if hasattr(cls, "row_model_name"):
4198 return cls.row_model_name
4200 return cls.get_row_model_class().__name__
4202 @classmethod
4203 def get_row_model_title(cls):
4204 """
4205 Returns the "humanized" (singular) title for the row model.
4207 The model title will be displayed to the user, so should have
4208 proper grammar and capitalization, e.g. ``"Batch Row"``.
4209 (Note this is *singular*, not plural.)
4211 The default logic will call :meth:`get_row_model_name()` and
4212 use the result as-is. Subclass may override by assigning
4213 :attr:`row_model_title`.
4215 See also :meth:`get_row_model_title_plural()`.
4216 """
4217 if hasattr(cls, "row_model_title"):
4218 return cls.row_model_title
4220 if model_class := cls.get_row_model_class():
4221 if hasattr(model_class, "__wutta_hint__"):
4222 if model_title := model_class.__wutta_hint__.get("model_title"):
4223 return model_title
4225 return cls.get_row_model_name()
4227 @classmethod
4228 def get_row_model_title_plural(cls):
4229 """
4230 Returns the "humanized" (plural) title for the row model.
4232 The model title will be displayed to the user, so should have
4233 proper grammar and capitalization, e.g. ``"Batch Rows"``.
4234 (Note this is *plural*, not singular.)
4236 The default logic will call :meth:`get_row_model_title()` and
4237 simply add a ``'s'`` to the end. Subclass may override by
4238 assigning :attr:`row_model_title_plural`.
4239 """
4240 if hasattr(cls, "row_model_title_plural"):
4241 return cls.row_model_title_plural
4243 if model_class := cls.get_row_model_class():
4244 if hasattr(model_class, "__wutta_hint__"):
4245 if model_title_plural := model_class.__wutta_hint__.get(
4246 "model_title_plural"
4247 ):
4248 return model_title_plural
4250 row_model_title = cls.get_row_model_title()
4251 return f"{row_model_title}s"
4253 ##############################
4254 # configuration
4255 ##############################
4257 @classmethod
4258 def defaults(cls, config):
4259 """
4260 Provide default Pyramid configuration for a master view.
4262 This is generally called from within the module's
4263 ``includeme()`` function, e.g.::
4265 from wuttaweb.views import MasterView
4267 class WidgetView(MasterView):
4268 model_name = 'Widget'
4270 def includeme(config):
4271 WidgetView.defaults(config)
4273 :param config: Reference to the app's
4274 :class:`pyramid:pyramid.config.Configurator` instance.
4275 """
4276 cls._defaults(config)
4278 @classmethod
4279 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches
4280 wutta_config = config.registry.settings.get("wutta_config")
4281 app = wutta_config.get_app()
4283 route_prefix = cls.get_route_prefix()
4284 permission_prefix = cls.get_permission_prefix()
4285 url_prefix = cls.get_url_prefix()
4286 model_title = cls.get_model_title()
4287 model_title_plural = cls.get_model_title_plural()
4289 # add to master view registry
4290 config.add_wutta_master_view(cls)
4292 # permission group
4293 config.add_wutta_permission_group(
4294 permission_prefix, model_title_plural, overwrite=False
4295 )
4297 # index
4298 if cls.listable:
4299 config.add_route(route_prefix, f"{url_prefix}/")
4300 config.add_view(
4301 cls,
4302 attr="index",
4303 route_name=route_prefix,
4304 permission=f"{permission_prefix}.list",
4305 )
4306 config.add_wutta_permission(
4307 permission_prefix,
4308 f"{permission_prefix}.list",
4309 f"Browse / search {model_title_plural}",
4310 )
4312 # create
4313 if cls.creatable:
4314 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new")
4315 config.add_view(
4316 cls,
4317 attr="create",
4318 route_name=f"{route_prefix}.create",
4319 permission=f"{permission_prefix}.create",
4320 )
4321 config.add_wutta_permission(
4322 permission_prefix,
4323 f"{permission_prefix}.create",
4324 f"Create new {model_title}",
4325 )
4327 # edit
4328 if cls.editable:
4329 instance_url_prefix = cls.get_instance_url_prefix()
4330 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit")
4331 config.add_view(
4332 cls,
4333 attr="edit",
4334 route_name=f"{route_prefix}.edit",
4335 permission=f"{permission_prefix}.edit",
4336 )
4337 config.add_wutta_permission(
4338 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}"
4339 )
4341 # delete
4342 if cls.deletable:
4343 instance_url_prefix = cls.get_instance_url_prefix()
4344 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete")
4345 config.add_view(
4346 cls,
4347 attr="delete",
4348 route_name=f"{route_prefix}.delete",
4349 permission=f"{permission_prefix}.delete",
4350 )
4351 config.add_wutta_permission(
4352 permission_prefix,
4353 f"{permission_prefix}.delete",
4354 f"Delete {model_title}",
4355 )
4357 # bulk delete
4358 if cls.deletable_bulk:
4359 config.add_route(
4360 f"{route_prefix}.delete_bulk",
4361 f"{url_prefix}/delete-bulk",
4362 request_method="POST",
4363 )
4364 config.add_view(
4365 cls,
4366 attr="delete_bulk",
4367 route_name=f"{route_prefix}.delete_bulk",
4368 permission=f"{permission_prefix}.delete_bulk",
4369 )
4370 config.add_wutta_permission(
4371 permission_prefix,
4372 f"{permission_prefix}.delete_bulk",
4373 f"Delete {model_title_plural} in bulk",
4374 )
4376 # merge
4377 if cls.mergeable:
4378 config.add_wutta_permission(
4379 permission_prefix,
4380 f"{permission_prefix}.merge",
4381 f"Merge 2 {model_title_plural}",
4382 )
4383 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge")
4384 config.add_view(
4385 cls,
4386 attr="merge",
4387 route_name=f"{route_prefix}.merge",
4388 permission=f"{permission_prefix}.merge",
4389 )
4391 # autocomplete
4392 if cls.has_autocomplete:
4393 config.add_route(
4394 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete"
4395 )
4396 config.add_view(
4397 cls,
4398 attr="autocomplete",
4399 route_name=f"{route_prefix}.autocomplete",
4400 renderer="json",
4401 permission=f"{route_prefix}.list",
4402 )
4404 # download
4405 if cls.downloadable:
4406 instance_url_prefix = cls.get_instance_url_prefix()
4407 config.add_route(
4408 f"{route_prefix}.download", f"{instance_url_prefix}/download"
4409 )
4410 config.add_view(
4411 cls,
4412 attr="download",
4413 route_name=f"{route_prefix}.download",
4414 permission=f"{permission_prefix}.download",
4415 )
4416 config.add_wutta_permission(
4417 permission_prefix,
4418 f"{permission_prefix}.download",
4419 f"Download file(s) for {model_title}",
4420 )
4422 # execute
4423 if cls.executable:
4424 instance_url_prefix = cls.get_instance_url_prefix()
4425 config.add_route(
4426 f"{route_prefix}.execute",
4427 f"{instance_url_prefix}/execute",
4428 request_method="POST",
4429 )
4430 config.add_view(
4431 cls,
4432 attr="execute",
4433 route_name=f"{route_prefix}.execute",
4434 permission=f"{permission_prefix}.execute",
4435 )
4436 config.add_wutta_permission(
4437 permission_prefix,
4438 f"{permission_prefix}.execute",
4439 f"Execute {model_title}",
4440 )
4442 # configure
4443 if cls.configurable:
4444 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure")
4445 config.add_view(
4446 cls,
4447 attr="configure",
4448 route_name=f"{route_prefix}.configure",
4449 permission=f"{permission_prefix}.configure",
4450 )
4451 config.add_wutta_permission(
4452 permission_prefix,
4453 f"{permission_prefix}.configure",
4454 f"Configure {model_title_plural}",
4455 )
4457 # view
4458 # nb. always register this one last, so it does not take
4459 # priority over model-wide action routes, e.g. delete_bulk
4460 if cls.viewable:
4461 instance_url_prefix = cls.get_instance_url_prefix()
4462 config.add_route(f"{route_prefix}.view", instance_url_prefix)
4463 config.add_view(
4464 cls,
4465 attr="view",
4466 route_name=f"{route_prefix}.view",
4467 permission=f"{permission_prefix}.view",
4468 )
4469 config.add_wutta_permission(
4470 permission_prefix, f"{permission_prefix}.view", f"View {model_title}"
4471 )
4473 # version history
4474 if cls.is_versioned() and app.continuum_is_enabled():
4475 instance_url_prefix = cls.get_instance_url_prefix()
4476 config.add_wutta_permission(
4477 permission_prefix,
4478 f"{permission_prefix}.versions",
4479 f"View version history for {model_title}",
4480 )
4481 config.add_route(
4482 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/"
4483 )
4484 config.add_view(
4485 cls,
4486 attr="view_versions",
4487 route_name=f"{route_prefix}.versions",
4488 permission=f"{permission_prefix}.versions",
4489 )
4490 config.add_route(
4491 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}"
4492 )
4493 config.add_view(
4494 cls,
4495 attr="view_version",
4496 route_name=f"{route_prefix}.version",
4497 permission=f"{permission_prefix}.versions",
4498 )
4500 ##############################
4501 # row-specific routes
4502 ##############################
4504 # create row
4505 if cls.has_rows and cls.rows_creatable:
4506 config.add_wutta_permission(
4507 permission_prefix,
4508 f"{permission_prefix}.create_row",
4509 f'Create new "rows" for {model_title}',
4510 )
4511 config.add_route(
4512 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row"
4513 )
4514 config.add_view(
4515 cls,
4516 attr="create_row",
4517 route_name=f"{route_prefix}.create_row",
4518 permission=f"{permission_prefix}.create_row",
4519 )