Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / master.py: 100%
1117 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-06 22:20 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-06 22:20 -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:: has_grid_totals
198 Boolean indicating whether the main grid supports a "Show
199 Totals" feature; this is false by default.
201 See also :meth:`fetch_grid_totals()`.
203 .. attribute:: checkable
205 Boolean indicating whether the grid should expose per-row
206 checkboxes. This is passed along to set
207 :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid.
209 .. method:: grid_row_class(obj, data, i)
211 This method is *not* defined on the ``MasterView`` base class;
212 however if a subclass defines it then it will be automatically
213 used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
214 the main :meth:`index()` grid.
216 For more info see
217 :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
219 .. attribute:: filterable
221 Boolean indicating whether the grid for the :meth:`index()`
222 view should allow filtering of data. Default is ``True``.
224 This is used by :meth:`make_model_grid()` to set the grid's
225 :attr:`~wuttaweb.grids.base.Grid.filterable` flag.
227 .. attribute:: filter_defaults
229 Optional dict of default filter state.
231 This is used by :meth:`make_model_grid()` to set the grid's
232 :attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
234 Only relevant if :attr:`filterable` is true.
236 .. attribute:: sortable
238 Boolean indicating whether the grid for the :meth:`index()`
239 view should allow sorting of data. Default is ``True``.
241 This is used by :meth:`make_model_grid()` to set the grid's
242 :attr:`~wuttaweb.grids.base.Grid.sortable` flag.
244 See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
246 .. attribute:: sort_on_backend
248 Boolean indicating whether the grid data for the
249 :meth:`index()` view should be sorted on the backend. Default
250 is ``True``.
252 This is used by :meth:`make_model_grid()` to set the grid's
253 :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
255 Only relevant if :attr:`sortable` is true.
257 .. attribute:: sort_defaults
259 Optional list of default sorting info. Applicable for both
260 frontend and backend sorting.
262 This is used by :meth:`make_model_grid()` to set the grid's
263 :attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
265 Only relevant if :attr:`sortable` is true.
267 .. attribute:: paginated
269 Boolean indicating whether the grid data for the
270 :meth:`index()` view should be paginated. Default is ``True``.
272 This is used by :meth:`make_model_grid()` to set the grid's
273 :attr:`~wuttaweb.grids.base.Grid.paginated` flag.
275 .. attribute:: paginate_on_backend
277 Boolean indicating whether the grid data for the
278 :meth:`index()` view should be paginated on the backend.
279 Default is ``True``.
281 This is used by :meth:`make_model_grid()` to set the grid's
282 :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag.
284 .. attribute:: creatable
286 Boolean indicating whether the view model supports "creating" -
287 i.e. it should have a :meth:`create()` view. Default value is
288 ``True``.
290 .. attribute:: viewable
292 Boolean indicating whether the view model supports "viewing" -
293 i.e. it should have a :meth:`view()` view. Default value is
294 ``True``.
296 .. attribute:: editable
298 Boolean indicating whether the view model supports "editing" -
299 i.e. it should have an :meth:`edit()` view. Default value is
300 ``True``.
302 See also :meth:`is_editable()`.
304 .. attribute:: deletable
306 Boolean indicating whether the view model supports "deleting" -
307 i.e. it should have a :meth:`delete()` view. Default value is
308 ``True``.
310 See also :meth:`is_deletable()`.
312 .. attribute:: deletable_bulk
314 Boolean indicating whether the view model supports "bulk
315 deleting" - i.e. it should have a :meth:`delete_bulk()` view.
316 Default value is ``False``.
318 See also :attr:`deletable_bulk_quick`.
320 .. attribute:: deletable_bulk_quick
322 Boolean indicating whether the view model supports "quick" bulk
323 deleting, i.e. the operation is reliably quick enough that it
324 should happen *synchronously* with no progress indicator.
326 Default is ``False`` in which case a progress indicator is
327 shown while the bulk deletion is performed.
329 Only relevant if :attr:`deletable_bulk` is true.
331 .. attribute:: form_fields
333 List of fields for the model form.
335 This is optional; see also :meth:`get_form_fields()`.
337 .. attribute:: has_autocomplete
339 Boolean indicating whether the view model supports
340 "autocomplete" - i.e. it should have an :meth:`autocomplete()`
341 view. Default is ``False``.
343 .. attribute:: downloadable
345 Boolean indicating whether the view model supports
346 "downloading" - i.e. it should have a :meth:`download()` view.
347 Default is ``False``.
349 .. attribute:: executable
351 Boolean indicating whether the view model supports "executing"
352 - i.e. it should have an :meth:`execute()` view. Default is
353 ``False``.
355 .. attribute:: configurable
357 Boolean indicating whether the master view supports
358 "configuring" - i.e. it should have a :meth:`configure()` view.
359 Default value is ``False``.
361 .. attribute:: version_grid_columns
363 List of columns for the :meth:`view_versions()` view grid.
365 This is optional; see also :meth:`get_version_grid_columns()`.
367 .. attribute:: mergeable
369 Boolean indicating whether the view model supports "merging two
370 records" - i.e. it should have a :meth:`merge()` view. Default
371 value is ``False``.
373 .. attribute:: merge_additive_fields
375 Optional list of fields for which values are "additive" in
376 nature when merging two records. Only relevant if
377 :attr:`mergeable` is true.
379 See also :meth:`merge_get_additive_fields()`.
381 .. attribute:: merge_coalesce_fields
383 Optional list of fields for which values should be "coalesced"
384 when merging two records. Only relevant if :attr:`mergeable`
385 is true.
387 See also :meth:`merge_get_coalesce_fields()`.
389 .. attribute:: merge_simple_fields
391 Optional list of "simple" fields when merging two records.
392 Only relevant if :attr:`mergeable` is true.
394 See also :meth:`merge_get_simple_fields()`.
396 **ROW FEATURES**
398 .. attribute:: has_rows
400 Whether the model has "child rows" which should also be
401 displayed when viewing model records. For instance when
402 viewing a :term:`batch` you want to see both the batch header
403 as well as its row data.
405 This the "master switch" for all row features; if this is turned
406 on then many other things kick in.
408 See also :attr:`row_model_class`.
410 .. attribute:: row_model_class
412 Reference to the :term:`data model` class for the child rows.
414 Subclass should define this if :attr:`has_rows` is true.
416 View logic should not access this directly but instead call
417 :meth:`get_row_model_class()`.
419 .. attribute:: row_model_name
421 Optional override for the view's row model name,
422 e.g. ``'WuttaWidget'``.
424 Code should not access this directly but instead call
425 :meth:`get_row_model_name()`.
427 .. attribute:: row_model_title
429 Optional override for the view's "humanized" (singular) row
430 model title, e.g. ``"Wutta Widget"``.
432 Code should not access this directly but instead call
433 :meth:`get_row_model_title()`.
435 .. attribute:: row_model_title_plural
437 Optional override for the view's "humanized" (plural) row model
438 title, e.g. ``"Wutta Widgets"``.
440 Code should not access this directly but instead call
441 :meth:`get_row_model_title_plural()`.
443 .. attribute:: rows_title
445 Display title for the rows grid.
447 The base logic should not access this directly but instead call
448 :meth:`get_rows_title()`.
450 .. attribute:: row_grid_columns
452 List of columns for the row grid.
454 This is optional; see also :meth:`get_row_grid_columns()`.
456 .. attribute:: rows_viewable
458 Boolean indicating whether the row model supports "viewing" -
459 i.e. the row grid should have a "View" action. Default value
460 is ``False``.
462 (For now) If you enable this, you must also override
463 :meth:`get_row_action_url_view()`.
465 .. note::
466 This eventually will cause there to be a ``row_view`` route
467 to be configured as well.
469 .. attribute:: row_form_fields
471 List of fields for the row model form.
473 This is optional; see also :meth:`get_row_form_fields()`.
475 .. attribute:: rows_creatable
477 Boolean indicating whether the row model supports "creating" -
478 i.e. a route should be defined for :meth:`create_row()`.
479 Default value is ``False``.
480 """
482 ##############################
483 # attributes
484 ##############################
486 model_class = None
488 # features
489 listable = True
490 has_grid = True
491 has_grid_totals = False
492 checkable = False
493 filterable = True
494 filter_defaults = None
495 sortable = True
496 sort_on_backend = True
497 sort_defaults = None
498 paginated = True
499 paginate_on_backend = True
500 creatable = True
501 viewable = True
502 editable = True
503 deletable = True
504 deletable_bulk = False
505 deletable_bulk_quick = False
506 has_autocomplete = False
507 downloadable = False
508 executable = False
509 execute_progress_template = None
510 configurable = False
512 # merging
513 mergeable = False
514 merge_additive_fields = None
515 merge_coalesce_fields = None
516 merge_simple_fields = None
518 # row features
519 has_rows = False
520 row_model_class = None
521 rows_filterable = True
522 rows_filter_defaults = None
523 rows_sortable = True
524 rows_sort_on_backend = True
525 rows_sort_defaults = None
526 rows_paginated = True
527 rows_paginate_on_backend = True
528 rows_viewable = False
529 rows_creatable = False
531 # current action
532 listing = False
533 creating = False
534 viewing = False
535 editing = False
536 deleting = False
537 executing = False
538 configuring = False
540 # default DB session
541 Session = Session
543 ##############################
544 # index methods
545 ##############################
547 def index(self):
548 """
549 View to "list" (filter/browse) the model data.
551 This is the "default" view for the model and is what user sees
552 when visiting the "root" path under the :attr:`url_prefix`,
553 e.g. ``/widgets/``.
555 By default, this view is included only if :attr:`listable` is
556 true.
558 The default view logic will show a "grid" (table) with the
559 model data (unless :attr:`has_grid` is false).
561 See also related methods, which are called by this one:
563 * :meth:`make_model_grid()`
564 """
565 self.listing = True
567 context = {
568 "index_url": None, # nb. avoid title link since this *is* the index
569 }
571 if self.has_grid:
572 grid = self.make_model_grid()
574 # handle "full" vs. "partial" differently
575 if self.request.GET.get("partial"):
577 # so-called 'partial' requests get just data, no html
578 context = grid.get_vue_context()
579 if grid.paginated and grid.paginate_on_backend:
580 context["pager_stats"] = grid.get_vue_pager_stats()
581 return self.json_response(context)
583 # full, not partial
585 # nb. when user asks to reset view, it is via the query
586 # string. if so we then redirect to discard that.
587 if self.request.GET.get("reset-view"):
589 # nb. we want to preserve url hash if applicable
590 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
591 return self.redirect(self.request.current_route_url(**kw))
593 context["grid"] = grid
595 return self.render_to_response("index", context)
597 def fetch_grid_totals(self):
598 """
599 Should return the "totals info" for the main grid, if
600 applicable. Only relevant when :attr:`has_grid_totals` is
601 true.
603 This method is called "on demand" from the client side; totals
604 are not calculated / shown by default when a grid is first
605 displayed on the page.
607 Subclass should override this method to calculate and return
608 the customized info. Default logic within the template is
609 expecting a ``totals_html`` key within the dict; this will be
610 rendered as-is on the page. For instance::
612 def fetch_grid_totals(self):
614 from webhelpers2.html import HTML
616 # get current data set from grid
617 # nb. this will be filtered and sorted but *not*
618 # paginated; we want to include all results.
619 grid = self.make_model_grid(paginated=False)
620 rows = grid.get_visible_data()
622 # calculate total
623 foo_total = sum([row.foo_amount for row in rows])
625 # render as <span> tag
626 html = HTML.tag("span", c=f"Foo Total: {foo_total:0.2f}")
627 return {"totals_html": html}
629 :returns: Dict of totals info.
630 """
631 return {"totals_html": "TODO: totals go here"}
633 ##############################
634 # create methods
635 ##############################
637 def create(self):
638 """
639 View to "create" a new model record.
641 This usually corresponds to URL like ``/widgets/new``
643 By default, this route is included only if :attr:`creatable`
644 is true.
646 The default logic calls :meth:`make_create_form()` and shows
647 that to the user. When they submit valid data, it calls
648 :meth:`save_create_form()` and then
649 :meth:`redirect_after_create()`.
650 """
651 self.creating = True
652 form = self.make_create_form()
654 if form.validate():
655 session = self.Session()
656 try:
657 result = self.save_create_form(form)
658 # nb. must always flush to ensure primary key is set
659 session.flush()
660 except Exception as err: # pylint: disable=broad-exception-caught
661 log.warning("failed to save 'create' form", exc_info=True)
662 self.request.session.flash(f"Create failed: {err}", "error")
663 else:
664 return self.redirect_after_create(result)
666 context = {"form": form}
667 return self.render_to_response("create", context)
669 def make_create_form(self):
670 """
671 Make the "create" model form. This is called by
672 :meth:`create()`.
674 Default logic calls :meth:`make_model_form()`.
676 :returns: :class:`~wuttaweb.forms.base.Form` instance
677 """
678 return self.make_model_form(cancel_url_fallback=self.get_index_url())
680 def save_create_form(self, form):
681 """
682 Save the "create" form. This is called by :meth:`create()`.
684 Default logic calls :meth:`objectify()` and then
685 :meth:`persist()`. Subclass is expected to override for
686 non-standard use cases.
688 As for return value, by default it will be whatever came back
689 from the ``objectify()`` call. In practice a subclass can
690 return whatever it likes. The value is only used as input to
691 :meth:`redirect_after_create()`.
693 :returns: Usually the model instance, but can be "anything"
694 """
695 if hasattr(self, "create_save_form"): # pragma: no cover
696 warnings.warn(
697 "MasterView.create_save_form() method name is deprecated; "
698 f"please refactor to save_create_form() instead for {self.__class__.__name__}",
699 DeprecationWarning,
700 )
701 return self.create_save_form(form)
703 obj = self.objectify(form)
704 self.persist(obj)
705 return obj
707 def redirect_after_create(self, result):
708 """
709 Must return a redirect, following successful save of the
710 "create" form. This is called by :meth:`create()`.
712 By default this redirects to the "view" page for the new
713 record.
715 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
716 """
717 return self.redirect(self.get_action_url("view", result))
719 ##############################
720 # view methods
721 ##############################
723 def view(self):
724 """
725 View to "view" a model record.
727 This usually corresponds to URL like ``/widgets/XXX``
729 By default, this route is included only if :attr:`viewable` is
730 true.
732 The default logic here is as follows:
734 First, if :attr:`has_rows` is true then
735 :meth:`make_row_model_grid()` is called.
737 If ``has_rows`` is true *and* the request has certain special
738 params relating to the grid, control may exit early. Mainly
739 this happens when a "partial" page is requested, which means
740 we just return grid data and nothing else. (Used for backend
741 sorting and pagination etc.)
743 Otherwise :meth:`make_view_form()` is called, and the template
744 is rendered.
745 """
746 self.viewing = True
747 obj = self.get_instance()
748 context = {"instance": obj}
750 if self.has_rows:
752 # always make the grid first. note that it already knows
753 # to "reset" its params when that is requested.
754 grid = self.make_row_model_grid(obj)
756 # but if user did request a "reset" then we want to
757 # redirect so the query string gets cleared out
758 if self.request.GET.get("reset-view"):
760 # nb. we want to preserve url hash if applicable
761 kw = {"_query": None, "_anchor": self.request.GET.get("hash")}
762 return self.redirect(self.request.current_route_url(**kw))
764 # so-called 'partial' requests get just the grid data
765 if self.request.params.get("partial"):
766 context = grid.get_vue_context()
767 if grid.paginated and grid.paginate_on_backend:
768 context["pager_stats"] = grid.get_vue_pager_stats()
769 return self.json_response(context)
771 context["rows_grid"] = grid
773 context["form"] = self.make_view_form(obj)
774 context["xref_buttons"] = self.get_xref_buttons(obj)
775 return self.render_to_response("view", context)
777 def make_view_form(self, obj, readonly=True):
778 """
779 Make the "view" model form. This is called by
780 :meth:`view()`.
782 Default logic calls :meth:`make_model_form()`.
784 :returns: :class:`~wuttaweb.forms.base.Form` instance
785 """
786 return self.make_model_form(obj, readonly=readonly)
788 ##############################
789 # edit methods
790 ##############################
792 def edit(self):
793 """
794 View to "edit" a model record.
796 This usually corresponds to URL like ``/widgets/XXX/edit``
798 By default, this route is included only if :attr:`editable` is
799 true.
801 The default logic calls :meth:`make_edit_form()` and shows
802 that to the user. When they submit valid data, it calls
803 :meth:`save_edit_form()` and then
804 :meth:`redirect_after_edit()`.
805 """
806 self.editing = True
807 instance = self.get_instance()
808 form = self.make_edit_form(instance)
810 if form.validate():
811 try:
812 result = self.save_edit_form(form)
813 except Exception as err: # pylint: disable=broad-exception-caught
814 log.warning("failed to save 'edit' form", exc_info=True)
815 self.request.session.flash(f"Edit failed: {err}", "error")
816 else:
817 return self.redirect_after_edit(result)
819 context = {
820 "instance": instance,
821 "form": form,
822 }
823 return self.render_to_response("edit", context)
825 def make_edit_form(self, obj):
826 """
827 Make the "edit" model form. This is called by
828 :meth:`edit()`.
830 Default logic calls :meth:`make_model_form()`.
832 :returns: :class:`~wuttaweb.forms.base.Form` instance
833 """
834 return self.make_model_form(
835 obj, cancel_url_fallback=self.get_action_url("view", obj)
836 )
838 def save_edit_form(self, form):
839 """
840 Save the "edit" form. This is called by :meth:`edit()`.
842 Default logic calls :meth:`objectify()` and then
843 :meth:`persist()`. Subclass is expected to override for
844 non-standard use cases.
846 As for return value, by default it will be whatever came back
847 from the ``objectify()`` call. In practice a subclass can
848 return whatever it likes. The value is only used as input to
849 :meth:`redirect_after_edit()`.
851 :returns: Usually the model instance, but can be "anything"
852 """
853 if hasattr(self, "edit_save_form"): # pragma: no cover
854 warnings.warn(
855 "MasterView.edit_save_form() method name is deprecated; "
856 f"please refactor to save_edit_form() instead for {self.__class__.__name__}",
857 DeprecationWarning,
858 )
859 return self.edit_save_form(form)
861 obj = self.objectify(form)
862 self.persist(obj)
863 return obj
865 def redirect_after_edit(self, result):
866 """
867 Must return a redirect, following successful save of the
868 "edit" form. This is called by :meth:`edit()`.
870 By default this redirects to the "view" page for the record.
872 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
873 """
874 return self.redirect(self.get_action_url("view", result))
876 ##############################
877 # delete methods
878 ##############################
880 def delete(self):
881 """
882 View to "delete" a model record.
884 This usually corresponds to URL like ``/widgets/XXX/delete``
886 By default, this route is included only if :attr:`deletable`
887 is true.
889 The default logic calls :meth:`make_delete_form()` and shows
890 that to the user. When they submit, it calls
891 :meth:`save_delete_form()` and then
892 :meth:`redirect_after_delete()`.
893 """
894 self.deleting = True
895 instance = self.get_instance()
897 if not self.is_deletable(instance):
898 return self.redirect(self.get_action_url("view", instance))
900 form = self.make_delete_form(instance)
902 # nb. validate() often returns empty dict here
903 if form.validate() is not False:
905 try:
906 result = self.save_delete_form( # pylint: disable=assignment-from-none
907 form
908 )
909 except Exception as err: # pylint: disable=broad-exception-caught
910 log.warning("failed to save 'delete' form", exc_info=True)
911 self.request.session.flash(f"Delete failed: {err}", "error")
912 else:
913 return self.redirect_after_delete(result)
915 context = {
916 "instance": instance,
917 "form": form,
918 }
919 return self.render_to_response("delete", context)
921 def make_delete_form(self, obj):
922 """
923 Make the "delete" model form. This is called by
924 :meth:`delete()`.
926 Default logic calls :meth:`make_model_form()` but with a
927 twist:
929 The form proper is *not* readonly; this ensures the form has a
930 submit button etc. But then all fields in the form are
931 explicitly marked readonly.
933 :returns: :class:`~wuttaweb.forms.base.Form` instance
934 """
935 # nb. this form proper is not readonly..
936 form = self.make_model_form(
937 obj,
938 cancel_url_fallback=self.get_action_url("view", obj),
939 button_label_submit="DELETE Forever",
940 button_icon_submit="trash",
941 button_type_submit="is-danger",
942 )
944 # ..but *all* fields are readonly
945 form.readonly_fields = set(form.fields)
946 return form
948 def save_delete_form(self, form):
949 """
950 Save the "delete" form. This is called by :meth:`delete()`.
952 Default logic calls :meth:`delete_instance()`. Normally
953 subclass would override that for non-standard use cases, but
954 it could also/instead override this method.
956 As for return value, by default this returns ``None``. In
957 practice a subclass can return whatever it likes. The value
958 is only used as input to :meth:`redirect_after_delete()`.
960 :returns: Usually ``None``, but can be "anything"
961 """
962 if hasattr(self, "delete_save_form"): # pragma: no cover
963 warnings.warn(
964 "MasterView.delete_save_form() method name is deprecated; "
965 f"please refactor to save_delete_form() instead for {self.__class__.__name__}",
966 DeprecationWarning,
967 )
968 self.delete_save_form(form)
969 return
971 obj = form.model_instance
972 self.delete_instance(obj)
974 def redirect_after_delete(self, result): # pylint: disable=unused-argument
975 """
976 Must return a redirect, following successful save of the
977 "delete" form. This is called by :meth:`delete()`.
979 By default this redirects back to the :meth:`index()` page.
981 :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance
982 """
983 return self.redirect(self.get_index_url())
985 def delete_instance(self, obj):
986 """
987 Delete the given model instance.
989 As of yet there is no default logic for this method; it will
990 raise ``NotImplementedError``. Subclass should override if
991 needed.
993 This method is called by :meth:`save_delete_form()`.
994 """
995 session = self.app.get_session(obj)
996 session.delete(obj)
998 def delete_bulk(self):
999 """
1000 View to delete all records in the current :meth:`index()` grid
1001 data set, i.e. those matching current query.
1003 This usually corresponds to a URL like
1004 ``/widgets/delete-bulk``.
1006 By default, this view is included only if
1007 :attr:`deletable_bulk` is true.
1009 This view requires POST method. When it is finished deleting,
1010 user is redirected back to :meth:`index()` view.
1012 Subclass normally should not override this method, but rather
1013 one of the related methods which are called (in)directly by
1014 this one:
1016 * :meth:`delete_bulk_action()`
1017 """
1019 # get current data set from grid
1020 # nb. this must *not* be paginated, we need it all
1021 grid = self.make_model_grid(paginated=False)
1022 data = grid.get_visible_data()
1024 if self.deletable_bulk_quick:
1026 # delete it all and go back to listing
1027 self.delete_bulk_action(data)
1028 return self.redirect(self.get_index_url())
1030 # start thread for delete; show progress page
1031 route_prefix = self.get_route_prefix()
1032 key = f"{route_prefix}.delete_bulk"
1033 progress = self.make_progress(key, success_url=self.get_index_url())
1034 thread = threading.Thread(
1035 target=self.delete_bulk_thread,
1036 args=(data,),
1037 kwargs={"progress": progress},
1038 )
1039 thread.start()
1040 return self.render_progress(progress)
1042 def delete_bulk_thread( # pylint: disable=empty-docstring
1043 self, query, progress=None
1044 ):
1045 """ """
1046 session = self.app.make_session()
1047 records = query.with_session(session).all()
1049 def onerror():
1050 log.warning(
1051 "failed to delete %s results for %s",
1052 len(records),
1053 self.get_model_title_plural(),
1054 exc_info=True,
1055 )
1057 self.do_thread_body(
1058 self.delete_bulk_action,
1059 (records,),
1060 {"progress": progress},
1061 onerror,
1062 session=session,
1063 progress=progress,
1064 )
1066 def delete_bulk_action(self, data, progress=None):
1067 """
1068 This method performs the actual bulk deletion, for the given
1069 data set. This is called via :meth:`delete_bulk()`.
1071 Default logic will call :meth:`is_deletable()` for every data
1072 record, and if that returns true then it calls
1073 :meth:`delete_instance()`. A progress indicator will be
1074 updated if one is provided.
1076 Subclass should override if needed.
1077 """
1078 model_title_plural = self.get_model_title_plural()
1080 def delete(obj, i): # pylint: disable=unused-argument
1081 if self.is_deletable(obj):
1082 self.delete_instance(obj)
1084 self.app.progress_loop(
1085 delete, data, progress, message=f"Deleting {model_title_plural}"
1086 )
1088 def delete_bulk_make_button(self): # pylint: disable=empty-docstring
1089 """ """
1090 route_prefix = self.get_route_prefix()
1092 label = HTML.literal(
1093 '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}'
1094 )
1095 button = self.make_button(
1096 label,
1097 variant="is-danger",
1098 icon_left="trash",
1099 **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"},
1100 )
1102 form = HTML.tag(
1103 "form",
1104 method="post",
1105 action=self.request.route_url(f"{route_prefix}.delete_bulk"),
1106 ref="deleteResultsForm",
1107 class_="control",
1108 c=[
1109 render_csrf_token(self.request),
1110 button,
1111 ],
1112 )
1113 return form
1115 ##############################
1116 # version history methods
1117 ##############################
1119 @classmethod
1120 def is_versioned(cls):
1121 """
1122 Returns boolean indicating whether the model class is
1123 configured for SQLAlchemy-Continuum versioning.
1125 The default logic will directly inspect the model class, as
1126 returned by :meth:`get_model_class()`. Or you can override by
1127 setting the ``model_is_versioned`` attribute::
1129 class WidgetView(MasterView):
1130 model_class = Widget
1131 model_is_versioned = False
1133 See also :meth:`should_expose_versions()`.
1135 :returns: ``True`` if the model class is versioned; else
1136 ``False``.
1137 """
1138 if hasattr(cls, "model_is_versioned"):
1139 return cls.model_is_versioned
1141 model_class = cls.get_model_class()
1142 if hasattr(model_class, "__versioned__"):
1143 return True
1145 return False
1147 @classmethod
1148 def get_model_version_class(cls):
1149 """
1150 Returns the version class for the master model class.
1152 Should only be relevant if :meth:`is_versioned()` is true.
1153 """
1154 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1156 return continuum.version_class(cls.get_model_class())
1158 def should_expose_versions(self):
1159 """
1160 Returns boolean indicating whether versioning history should
1161 be exposed for the current user. This will return ``True``
1162 unless any of the following are ``False``:
1164 * :meth:`is_versioned()`
1165 * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()`
1166 * ``self.has_perm("versions")`` - cf. :meth:`has_perm()`
1168 :returns: ``True`` if versioning should be exposed for current
1169 user; else ``False``.
1170 """
1171 if not self.is_versioned():
1172 return False
1174 if not self.app.continuum_is_enabled():
1175 return False
1177 if not self.has_perm("versions"):
1178 return False
1180 return True
1182 def view_versions(self):
1183 """
1184 View to list version history for an object. See also
1185 :meth:`view_version()`.
1187 This usually corresponds to a URL like
1188 ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID
1189 for the record.
1191 By default, this view is included only if
1192 :meth:`is_versioned()` is true.
1194 The default view logic will show a "grid" (table) with the
1195 record's version history.
1197 See also:
1199 * :meth:`make_version_grid()`
1200 """
1201 instance = self.get_instance()
1202 instance_title = self.get_instance_title(instance)
1203 grid = self.make_version_grid(instance)
1205 # return grid data only, if partial page was requested
1206 if self.request.GET.get("partial"):
1207 context = grid.get_vue_context()
1208 if grid.paginated and grid.paginate_on_backend:
1209 context["pager_stats"] = grid.get_vue_pager_stats()
1210 return self.json_response(context)
1212 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1214 instance_link = tags.link_to(
1215 instance_title, self.get_action_url("view", instance)
1216 )
1218 index_title_rendered = HTML.literal("<span> »</span>").join(
1219 [index_link, instance_link]
1220 )
1222 return self.render_to_response(
1223 "view_versions",
1224 {
1225 "index_title_rendered": index_title_rendered,
1226 "instance": instance,
1227 "instance_title": instance_title,
1228 "instance_url": self.get_action_url("view", instance),
1229 "grid": grid,
1230 },
1231 )
1233 def make_version_grid(self, instance=None, **kwargs):
1234 """
1235 Create and return a grid for use with the
1236 :meth:`view_versions()` view.
1238 See also related methods, which are called by this one:
1240 * :meth:`get_version_grid_key()`
1241 * :meth:`get_version_grid_columns()`
1242 * :meth:`get_version_grid_data()`
1243 * :meth:`configure_version_grid()`
1245 :returns: :class:`~wuttaweb.grids.base.Grid` instance
1246 """
1247 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1249 route_prefix = self.get_route_prefix()
1250 # instance = kwargs.pop("instance", None)
1251 if not instance:
1252 instance = self.get_instance()
1254 if "key" not in kwargs:
1255 kwargs["key"] = self.get_version_grid_key()
1257 if "model_class" not in kwargs:
1258 kwargs["model_class"] = continuum.transaction_class(self.get_model_class())
1260 if "columns" not in kwargs:
1261 kwargs["columns"] = self.get_version_grid_columns()
1263 if "data" not in kwargs:
1264 kwargs["data"] = self.get_version_grid_data(instance)
1266 if "actions" not in kwargs:
1267 route = f"{route_prefix}.version"
1269 def url(txn, i): # pylint: disable=unused-argument
1270 return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id)
1272 kwargs["actions"] = [
1273 self.make_grid_action("view", icon="eye", url=url),
1274 ]
1276 kwargs.setdefault("paginated", True)
1278 grid = self.make_grid(**kwargs)
1279 self.configure_version_grid(grid)
1280 grid.load_settings()
1281 return grid
1283 @classmethod
1284 def get_version_grid_key(cls):
1285 """
1286 Returns the unique key to be used for the version grid, for caching
1287 sort/filter options etc.
1289 This is normally called automatically from :meth:`make_version_grid()`.
1291 :returns: Grid key as string
1292 """
1293 if hasattr(cls, "version_grid_key"):
1294 return cls.version_grid_key
1295 return f"{cls.get_route_prefix()}.history"
1297 def get_version_grid_columns(self):
1298 """
1299 Returns the default list of version grid column names, for the
1300 :meth:`view_versions()` view.
1302 This is normally called automatically by
1303 :meth:`make_version_grid()`.
1305 Subclass may define :attr:`version_grid_columns` for simple
1306 cases, or can override this method if needed.
1308 :returns: List of string column names
1309 """
1310 if hasattr(self, "version_grid_columns"):
1311 return self.version_grid_columns
1313 return [
1314 "id",
1315 "issued_at",
1316 "user",
1317 "remote_addr",
1318 "comment",
1319 ]
1321 def get_version_grid_data(self, instance):
1322 """
1323 Returns the grid data query for the :meth:`view_versions()`
1324 view.
1326 This is normally called automatically by
1327 :meth:`make_version_grid()`.
1329 Default query will locate SQLAlchemy-Continuum ``transaction``
1330 records which are associated with versions of the given model
1331 instance. See also:
1333 * :meth:`get_version_joins()`
1334 * :meth:`normalize_version_joins()`
1335 * :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
1337 :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
1338 """
1339 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1340 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1341 model_transaction_query,
1342 )
1344 model_class = self.get_model_class()
1345 txncls = continuum.transaction_class(model_class)
1346 query = model_transaction_query(instance, joins=self.normalize_version_joins())
1347 return query.order_by(txncls.issued_at.desc())
1349 def get_version_joins(self):
1350 """
1351 Override this method to declare additional version tables
1352 which should be joined when showing the overall revision
1353 history for a given model instance.
1355 Note that whatever this method returns, will be ran through
1356 :meth:`normalize_version_joins()` before being passed along to
1357 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1359 :returns: List of version joins info as described below.
1361 In the simple scenario where an "extension" table is involved,
1362 e.g. a ``UserExtension`` table::
1364 def get_version_joins(self):
1365 model = self.app.model
1366 return super().get_version_joins() + [
1367 model.UserExtension,
1368 ]
1370 In the case where a secondary table is "related" to the main
1371 model table, but not a standard extension (using the
1372 ``User.person`` relationship as example)::
1374 def get_version_joins(self):
1375 model = self.app.model
1376 return super().get_version_joins() + [
1377 (model.Person, "uuid", "person_uuid"),
1378 ]
1380 See also :meth:`get_version_grid_data()`.
1381 """
1382 return []
1384 def normalize_version_joins(self):
1385 """
1386 This method calls :meth:`get_version_joins()` and normalizes
1387 the result, which will then get passed along to
1388 :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
1390 Subclass should (generally) not override this, but instead
1391 override :meth:`get_version_joins()`.
1393 Each element in the return value (list) will be a 3-tuple
1394 conforming to what is needed for the query function.
1396 See also :meth:`get_version_grid_data()`.
1398 :returns: List of version joins info.
1399 """
1400 joins = []
1401 for join in self.get_version_joins():
1402 if not isinstance(join, tuple):
1403 join = (join, "uuid", "uuid")
1404 joins.append(join)
1405 return joins
1407 def configure_version_grid(self, g):
1408 """
1409 Configure the grid for the :meth:`view_versions()` view.
1411 This is called automatically by :meth:`make_version_grid()`.
1413 Default logic applies basic customization to the column labels etc.
1414 """
1415 # id
1416 g.set_label("id", "TXN ID")
1417 # g.set_link("id")
1419 # issued_at
1420 g.set_label("issued_at", "Changed")
1421 g.set_link("issued_at")
1422 g.set_sort_defaults("issued_at", "desc")
1424 # user
1425 g.set_label("user", "Changed by")
1426 g.set_link("user")
1428 # remote_addr
1429 g.set_label("remote_addr", "IP Address")
1431 # comment
1432 g.set_renderer("comment", self.render_version_comment)
1434 def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument
1435 self, txn, key, value
1436 ):
1437 return txn.meta.get("comment", "")
1439 def view_version(self): # pylint: disable=too-many-locals
1440 """
1441 View to show diff details for a particular object version.
1442 See also :meth:`view_versions()`.
1444 This usually corresponds to a URL like
1445 ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the
1446 key/ID for the record and YYY represents a
1447 SQLAlchemy-Continuum ``transaction.id``.
1449 By default, this view is included only if
1450 :meth:`is_versioned()` is true.
1452 The default view logic will display a "diff" table showing how
1453 the record's values were changed within a transaction.
1455 See also:
1457 * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`
1458 * :meth:`get_relevant_versions()`
1459 * :class:`~wuttaweb.diffs.VersionDiff`
1460 """
1461 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1462 from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel
1463 model_transaction_query,
1464 )
1466 instance = self.get_instance()
1467 model_class = self.get_model_class()
1468 route_prefix = self.get_route_prefix()
1469 txncls = continuum.transaction_class(model_class)
1470 transactions = model_transaction_query(
1471 instance, joins=self.normalize_version_joins()
1472 )
1474 txnid = self.request.matchdict["txnid"]
1475 txn = transactions.filter(txncls.id == txnid).first()
1476 if not txn:
1477 raise self.notfound()
1479 prev_url = None
1480 older = (
1481 transactions.filter(txncls.issued_at <= txn.issued_at)
1482 .filter(txncls.id != txnid)
1483 .order_by(txncls.issued_at.desc())
1484 .first()
1485 )
1486 if older:
1487 prev_url = self.request.route_url(
1488 f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id
1489 )
1491 next_url = None
1492 newer = (
1493 transactions.filter(txncls.issued_at >= txn.issued_at)
1494 .filter(txncls.id != txnid)
1495 .order_by(txncls.issued_at)
1496 .first()
1497 )
1498 if newer:
1499 next_url = self.request.route_url(
1500 f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id
1501 )
1503 version_diffs = [
1504 VersionDiff(self.config, version)
1505 for version in self.get_relevant_versions(txn, instance)
1506 ]
1508 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
1510 instance_title = self.get_instance_title(instance)
1511 instance_link = tags.link_to(
1512 instance_title, self.get_action_url("view", instance)
1513 )
1515 history_link = tags.link_to(
1516 "history",
1517 self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid),
1518 )
1520 index_title_rendered = HTML.literal("<span> »</span>").join(
1521 [index_link, instance_link, history_link]
1522 )
1524 return self.render_to_response(
1525 "view_version",
1526 {
1527 "index_title_rendered": index_title_rendered,
1528 "instance": instance,
1529 "instance_title": instance_title,
1530 "instance_url": self.get_action_url("versions", instance),
1531 "transaction": txn,
1532 "changed": self.app.render_datetime(txn.issued_at, html=True),
1533 "version_diffs": version_diffs,
1534 "show_prev_next": True,
1535 "prev_url": prev_url,
1536 "next_url": next_url,
1537 },
1538 )
1540 def get_relevant_versions(self, transaction, instance):
1541 """
1542 Should return all version records pertaining to the given
1543 model instance and transaction.
1545 This is normally called from :meth:`view_version()`.
1547 :param transaction: SQLAlchemy-Continuum ``transaction``
1548 record/instance.
1550 :param instance: Instance of the model class.
1552 :returns: List of version records.
1553 """
1554 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
1556 session = self.Session()
1557 vercls = self.get_model_version_class()
1558 versions = []
1560 # first get all versions for the model instance proper
1561 versions.extend(
1562 session.query(vercls)
1563 .filter(vercls.transaction == transaction)
1564 .filter(vercls.uuid == instance.uuid)
1565 .all()
1566 )
1568 # then get all related versions, per declared joins
1569 for child_class, foreign_attr, primary_attr in self.normalize_version_joins():
1570 child_vercls = continuum.version_class(child_class)
1571 versions.extend(
1572 session.query(child_vercls)
1573 .filter(child_vercls.transaction == transaction)
1574 .filter(
1575 getattr(child_vercls, foreign_attr)
1576 == getattr(instance, primary_attr)
1577 )
1578 )
1580 return versions
1582 ##############################
1583 # autocomplete methods
1584 ##############################
1586 def autocomplete(self):
1587 """
1588 View which accepts a single ``term`` param, and returns a JSON
1589 list of autocomplete results to match.
1591 By default, this view is included only if
1592 :attr:`has_autocomplete` is true. It usually maps to a URL
1593 like ``/widgets/autocomplete``.
1595 Subclass generally does not need to override this method, but
1596 rather should override the others which this calls:
1598 * :meth:`autocomplete_data()`
1599 * :meth:`autocomplete_normalize()`
1600 """
1601 term = self.request.GET.get("term", "")
1602 if not term:
1603 return []
1605 data = self.autocomplete_data(term) # pylint: disable=assignment-from-none
1606 if not data:
1607 return []
1609 max_results = 100 # TODO
1611 results = []
1612 for obj in data[:max_results]:
1613 normal = self.autocomplete_normalize(obj)
1614 if normal:
1615 results.append(normal)
1617 return results
1619 def autocomplete_data(self, term): # pylint: disable=unused-argument
1620 """
1621 Should return the data/query for the "matching" model records,
1622 based on autocomplete search term. This is called by
1623 :meth:`autocomplete()`.
1625 Subclass must override this; default logic returns no data.
1627 :param term: String search term as-is from user, e.g. "foo bar".
1629 :returns: List of data records, or SQLAlchemy query.
1630 """
1631 return None
1633 def autocomplete_normalize(self, obj):
1634 """
1635 Should return a "normalized" version of the given model
1636 record, suitable for autocomplete JSON results. This is
1637 called by :meth:`autocomplete()`.
1639 Subclass may need to override this; default logic is
1640 simplistic but will work for basic models. It returns the
1641 "autocomplete results" dict for the object::
1643 {
1644 'value': obj.uuid,
1645 'label': str(obj),
1646 }
1648 The 2 keys shown are required; any other keys will be ignored
1649 by the view logic but may be useful on the frontend widget.
1651 :param obj: Model record/instance.
1653 :returns: Dict of "autocomplete results" format, as shown
1654 above.
1655 """
1656 return {
1657 "value": obj.uuid,
1658 "label": str(obj),
1659 }
1661 ##############################
1662 # download methods
1663 ##############################
1665 def download(self):
1666 """
1667 View to download a file associated with a model record.
1669 This usually corresponds to a URL like
1670 ``/widgets/XXX/download`` where ``XXX`` represents the key/ID
1671 for the record.
1673 By default, this view is included only if :attr:`downloadable`
1674 is true.
1676 This method will (try to) locate the file on disk, and return
1677 it as a file download response to the client.
1679 The GET request for this view may contain a ``filename`` query
1680 string parameter, which can be used to locate one of various
1681 files associated with the model record. This filename is
1682 passed to :meth:`download_path()` for locating the file.
1684 For instance: ``/widgets/XXX/download?filename=widget-specs.txt``
1686 Subclass normally should not override this method, but rather
1687 one of the related methods which are called (in)directly by
1688 this one:
1690 * :meth:`download_path()`
1691 """
1692 obj = self.get_instance()
1693 filename = self.request.GET.get("filename", None)
1695 path = self.download_path(obj, filename) # pylint: disable=assignment-from-none
1696 if not path or not os.path.exists(path):
1697 return self.notfound()
1699 return self.file_response(path)
1701 def download_path(self, obj, filename): # pylint: disable=unused-argument
1702 """
1703 Should return absolute path on disk, for the given object and
1704 filename. Result will be used to return a file response to
1705 client. This is called by :meth:`download()`.
1707 Default logic always returns ``None``; subclass must override.
1709 :param obj: Refefence to the model instance.
1711 :param filename: Name of file for which to retrieve the path.
1713 :returns: Path to file, or ``None`` if not found.
1715 Note that ``filename`` may be ``None`` in which case the "default"
1716 file path should be returned, if applicable.
1718 If this method returns ``None`` (as it does by default) then
1719 the :meth:`download()` view will return a 404 not found
1720 response.
1721 """
1722 return None
1724 ##############################
1725 # execute methods
1726 ##############################
1728 def execute(self):
1729 """
1730 View to "execute" a model record. Requires a POST request.
1732 This usually corresponds to a URL like
1733 ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID
1734 for the record.
1736 By default, this view is included only if :attr:`executable` is
1737 true.
1739 Probably this is a "rare" view to implement for a model. But
1740 there are two notable use cases so far, namely:
1742 * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`)
1743 * batches (not yet implemented;
1744 cf. :doc:`rattail-manual:data/batch/index` in Rattail
1745 Manual)
1747 The general idea is to take some "irrevocable" action
1748 associated with the model record. In the case of upgrades, it
1749 is to run the upgrade script. For batches it is to "push
1750 live" the data held within the batch.
1752 Subclass normally should not override this method, but rather
1753 one of the related methods which are called (in)directly by
1754 this one:
1756 * :meth:`execute_instance()`
1757 """
1758 route_prefix = self.get_route_prefix()
1759 model_title = self.get_model_title()
1760 obj = self.get_instance()
1762 # make the progress tracker
1763 progress = self.make_progress(
1764 f"{route_prefix}.execute",
1765 success_msg=f"{model_title} was executed.",
1766 success_url=self.get_action_url("view", obj),
1767 )
1769 # start thread for execute; show progress page
1770 key = self.request.matchdict
1771 thread = threading.Thread(
1772 target=self.execute_thread,
1773 args=(key, self.request.user.uuid),
1774 kwargs={"progress": progress},
1775 )
1776 thread.start()
1777 return self.render_progress(
1778 progress,
1779 context={
1780 "instance": obj,
1781 },
1782 template=self.execute_progress_template,
1783 )
1785 def execute_instance(self, obj, user, progress=None):
1786 """
1787 Perform the actual "execution" logic for a model record.
1788 Called by :meth:`execute()`.
1790 This method does nothing by default; subclass must override.
1792 :param obj: Reference to the model instance.
1794 :param user: Reference to the
1795 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
1796 is doing the execute.
1798 :param progress: Optional progress indicator factory.
1799 """
1801 def execute_thread( # pylint: disable=empty-docstring
1802 self, key, user_uuid, progress=None
1803 ):
1804 """ """
1805 model = self.app.model
1806 model_title = self.get_model_title()
1808 # nb. use new session, separate from web transaction
1809 session = self.app.make_session()
1811 # fetch model instance and user for this session
1812 obj = self.get_instance(session=session, matchdict=key)
1813 user = session.get(model.User, user_uuid)
1815 try:
1816 self.execute_instance(obj, user, progress=progress)
1818 except Exception as error: # pylint: disable=broad-exception-caught
1819 session.rollback()
1820 log.warning("%s failed to execute: %s", model_title, obj, exc_info=True)
1821 if progress:
1822 progress.handle_error(error)
1824 else:
1825 session.commit()
1826 if progress:
1827 progress.handle_success()
1829 finally:
1830 session.close()
1832 ##############################
1833 # configure methods
1834 ##############################
1836 def configure(self, session=None):
1837 """
1838 View for configuring aspects of the app which are pertinent to
1839 this master view and/or model.
1841 By default, this view is included only if :attr:`configurable`
1842 is true. It usually maps to a URL like ``/widgets/configure``.
1844 The expected workflow is as follows:
1846 * user navigates to Configure page
1847 * user modifies settings and clicks Save
1848 * this view then *deletes* all "known" settings
1849 * then it saves user-submitted settings
1851 That is unless ``remove_settings`` is requested, in which case
1852 settings are deleted but then none are saved. The "known"
1853 settings by default include only the "simple" settings.
1855 As a general rule, a particular setting should be configurable
1856 by (at most) one master view. Some settings may never be
1857 exposed at all. But when exposing a setting, careful thought
1858 should be given to where it logically/best belongs.
1860 Some settings are "simple" and a master view subclass need
1861 only provide their basic definitions via
1862 :meth:`configure_get_simple_settings()`. If complex settings
1863 are needed, subclass must override one or more other methods
1864 to achieve the aim(s).
1866 See also related methods, used by this one:
1868 * :meth:`configure_get_simple_settings()`
1869 * :meth:`configure_get_context()`
1870 * :meth:`configure_gather_settings()`
1871 * :meth:`configure_remove_settings()`
1872 * :meth:`configure_save_settings()`
1873 """
1874 self.configuring = True
1875 config_title = self.get_config_title()
1877 # was form submitted?
1878 if self.request.method == "POST":
1880 # maybe just remove settings
1881 if self.request.POST.get("remove_settings"):
1882 self.configure_remove_settings(session=session)
1883 self.request.session.flash(
1884 f"All settings for {config_title} have been removed.", "warning"
1885 )
1887 # reload configure page
1888 return self.redirect(self.request.current_route_url())
1890 # gather/save settings
1891 data = get_form_data(self.request)
1892 settings = self.configure_gather_settings(data)
1893 self.configure_remove_settings(session=session)
1894 self.configure_save_settings(settings, session=session)
1895 self.request.session.flash("Settings have been saved.")
1897 # reload configure page
1898 return self.redirect(self.request.url)
1900 # render configure page
1901 context = self.configure_get_context()
1902 return self.render_to_response("configure", context)
1904 def configure_get_context(
1905 self,
1906 simple_settings=None,
1907 ):
1908 """
1909 Returns the full context dict, for rendering the
1910 :meth:`configure()` page template.
1912 Default context will include ``simple_settings`` (normalized
1913 to just name/value).
1915 You may need to override this method, to add additional
1916 "complex" settings etc.
1918 :param simple_settings: Optional list of simple settings, if
1919 already initialized. Otherwise it is retrieved via
1920 :meth:`configure_get_simple_settings()`.
1922 :returns: Context dict for the page template.
1923 """
1924 context = {}
1926 # simple settings
1927 if simple_settings is None:
1928 simple_settings = self.configure_get_simple_settings()
1929 if simple_settings:
1931 # we got some, so "normalize" each definition to name/value
1932 normalized = {}
1933 for simple in simple_settings:
1935 # name
1936 name = simple["name"]
1938 # value
1939 if "value" in simple:
1940 value = simple["value"]
1941 elif simple.get("type") is bool:
1942 value = self.config.get_bool(
1943 name, default=simple.get("default", False)
1944 )
1945 else:
1946 value = self.config.get(name, default=simple.get("default"))
1948 normalized[name] = value
1950 # add to template context
1951 context["simple_settings"] = normalized
1953 return context
1955 def configure_get_simple_settings(self):
1956 """
1957 This should return a list of "simple" setting definitions for
1958 the :meth:`configure()` view, which can be handled in a more
1959 automatic way. (This is as opposed to some settings which are
1960 more complex and must be handled manually; those should not be
1961 part of this method's return value.)
1963 Basically a "simple" setting is one which can be represented
1964 by a single field/widget on the Configure page.
1966 The setting definitions returned must each be a dict of
1967 "attributes" for the setting. For instance a *very* simple
1968 setting might be::
1970 {'name': 'wutta.app_title'}
1972 The ``name`` is required, everything else is optional. Here
1973 is a more complete example::
1975 {
1976 'name': 'wutta.production',
1977 'type': bool,
1978 'default': False,
1979 'save_if_empty': False,
1980 }
1982 Note that if specified, the ``default`` should be of the same
1983 data type as defined for the setting (``bool`` in the above
1984 example). The default ``type`` is ``str``.
1986 Normally if a setting's value is effectively null, the setting
1987 is removed instead of keeping it in the DB. This behavior can
1988 be changed per-setting via the ``save_if_empty`` flag.
1990 :returns: List of setting definition dicts as described above.
1991 Note that their order does not matter since the template
1992 must explicitly define field layout etc.
1993 """
1994 return []
1996 def configure_gather_settings(
1997 self,
1998 data,
1999 simple_settings=None,
2000 ):
2001 """
2002 Collect the full set of "normalized" settings from user
2003 request, so that :meth:`configure()` can save them.
2005 Settings are gathered from the given request (e.g. POST)
2006 ``data``, but also taking into account what we know based on
2007 the simple setting definitions.
2009 Subclass may need to override this method if complex settings
2010 are required.
2012 :param data: Form data submitted via POST request.
2014 :param simple_settings: Optional list of simple settings, if
2015 already initialized. Otherwise it is retrieved via
2016 :meth:`configure_get_simple_settings()`.
2018 This method must return a list of normalized settings, similar
2019 in spirit to the definition syntax used in
2020 :meth:`configure_get_simple_settings()`. However the format
2021 returned here is minimal and contains just name/value::
2023 {
2024 'name': 'wutta.app_title',
2025 'value': 'Wutta Wutta',
2026 }
2028 Note that the ``value`` will always be a string.
2030 Also note, whereas it's possible ``data`` will not contain all
2031 known settings, the return value *should* (potentially)
2032 contain all of them.
2034 The one exception is when a simple setting has null value, by
2035 default it will not be included in the result (hence, not
2036 saved to DB) unless the setting definition has the
2037 ``save_if_empty`` flag set.
2038 """
2039 settings = []
2041 # simple settings
2042 if simple_settings is None:
2043 simple_settings = self.configure_get_simple_settings()
2044 if simple_settings:
2046 # we got some, so "normalize" each definition to name/value
2047 for simple in simple_settings:
2048 name = simple["name"]
2050 if name in data:
2051 value = data[name]
2052 elif simple.get("type") is bool:
2053 # nb. bool false will be *missing* from data
2054 value = False
2055 else:
2056 value = simple.get("default")
2058 if simple.get("type") is bool:
2059 value = str(bool(value)).lower()
2060 elif simple.get("type") is int:
2061 value = str(int(value or "0"))
2062 elif value is None:
2063 value = ""
2064 else:
2065 value = str(value)
2067 # only want to save this setting if we received a
2068 # value, or if empty values are okay to save
2069 if value or simple.get("save_if_empty"):
2070 settings.append({"name": name, "value": value})
2072 return settings
2074 def configure_remove_settings(
2075 self,
2076 simple_settings=None,
2077 session=None,
2078 ):
2079 """
2080 Remove all "known" settings from the DB; this is called by
2081 :meth:`configure()`.
2083 The point of this method is to ensure *all* "known" settings
2084 which are managed by this master view, are purged from the DB.
2086 The default logic can handle this automatically for simple
2087 settings; subclass must override for any complex settings.
2089 :param simple_settings: Optional list of simple settings, if
2090 already initialized. Otherwise it is retrieved via
2091 :meth:`configure_get_simple_settings()`.
2092 """
2093 names = []
2095 # simple settings
2096 if simple_settings is None:
2097 simple_settings = self.configure_get_simple_settings()
2098 if simple_settings:
2099 names.extend([simple["name"] for simple in simple_settings])
2101 if names:
2102 # nb. must avoid self.Session here in case that does not
2103 # point to our primary app DB
2104 session = session or self.Session()
2105 for name in names:
2106 self.app.delete_setting(session, name)
2108 def configure_save_settings(self, settings, session=None):
2109 """
2110 Save the given settings to the DB; this is called by
2111 :meth:`configure()`.
2113 This method expects a list of name/value dicts and will simply
2114 save each to the DB, with no "conversion" logic.
2116 :param settings: List of normalized setting definitions, as
2117 returned by :meth:`configure_gather_settings()`.
2118 """
2119 # nb. must avoid self.Session here in case that does not point
2120 # to our primary app DB
2121 session = session or self.Session()
2122 for setting in settings:
2123 self.app.save_setting(
2124 session, setting["name"], setting["value"], force_create=True
2125 )
2127 ##############################
2128 # grid rendering methods
2129 ##############################
2131 def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument
2132 """
2133 Custom grid value renderer for "boolean" fields.
2135 This converts a bool value to "Yes" or "No" - unless the value
2136 is ``None`` in which case this renders empty string.
2137 To use this feature for your grid::
2139 grid.set_renderer('my_bool_field', self.grid_render_bool)
2140 """
2141 if value is None:
2142 return None
2144 return "Yes" if value else "No"
2146 def grid_render_currency(self, record, key, value, scale=2):
2147 """
2148 Custom grid value renderer for "currency" fields.
2150 This expects float or decimal values, and will round the
2151 decimal as appropriate, and add the currency symbol.
2153 :param scale: Number of decimal digits to be displayed;
2154 default is 2 places.
2156 To use this feature for your grid::
2158 grid.set_renderer('my_currency_field', self.grid_render_currency)
2160 # you can also override scale
2161 grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
2162 """
2164 # nb. get new value since the one provided will just be a
2165 # (json-safe) *string* if the original type was Decimal
2166 value = record[key]
2168 if value is None:
2169 return None
2171 if value < 0:
2172 fmt = f"(${{:0,.{scale}f}})"
2173 return fmt.format(0 - value)
2175 fmt = f"${{:0,.{scale}f}}"
2176 return fmt.format(value)
2178 def grid_render_datetime( # pylint: disable=empty-docstring
2179 self, record, key, value, fmt=None
2180 ):
2181 """ """
2182 warnings.warn(
2183 "MasterView.grid_render_datetime() is deprecated; "
2184 "please use app.render_datetime() directly instead",
2185 DeprecationWarning,
2186 stacklevel=2,
2187 )
2189 # nb. get new value since the one provided will just be a
2190 # (json-safe) *string* if the original type was datetime
2191 value = record[key]
2193 if value is None:
2194 return None
2196 return value.strftime(fmt or "%Y-%m-%d %I:%M:%S %p")
2198 def grid_render_enum(self, record, key, value, enum=None):
2199 """
2200 Custom grid value renderer for "enum" fields.
2202 :param enum: Enum class for the field. This should be an
2203 instance of :class:`~python:enum.Enum`.
2205 To use this feature for your grid::
2207 from enum import Enum
2209 class MyEnum(Enum):
2210 ONE = 1
2211 TWO = 2
2212 THREE = 3
2214 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum)
2215 """
2216 if enum:
2217 original = record[key]
2218 if original:
2219 return original.name
2221 return value
2223 def grid_render_notes( # pylint: disable=unused-argument
2224 self, record, key, value, maxlen=100
2225 ):
2226 """
2227 Custom grid value renderer for "notes" fields.
2229 If the given text ``value`` is shorter than ``maxlen``
2230 characters, it is returned as-is.
2232 But if it is longer, then it is truncated and an ellispsis is
2233 added. The resulting ``<span>`` tag is also given a ``title``
2234 attribute with the original (full) text, so that appears on
2235 mouse hover.
2237 To use this feature for your grid::
2239 grid.set_renderer('my_notes_field', self.grid_render_notes)
2241 # you can also override maxlen
2242 grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
2243 """
2244 if value is None:
2245 return None
2247 if len(value) < maxlen:
2248 return value
2250 return HTML.tag("span", title=value, c=f"{value[:maxlen]}...")
2252 ##############################
2253 # support methods
2254 ##############################
2256 def get_class_hierarchy(self, topfirst=True):
2257 """
2258 Convenience to return a list of classes from which the current
2259 class inherits.
2261 This is a wrapper around
2262 :func:`wuttjamaican.util.get_class_hierarchy()`.
2263 """
2264 return get_class_hierarchy(self.__class__, topfirst=topfirst)
2266 def has_perm(self, name):
2267 """
2268 Shortcut to check if current user has the given permission.
2270 This will automatically add the :attr:`permission_prefix` to
2271 ``name`` before passing it on to
2272 :func:`~wuttaweb.subscribers.request.has_perm()`.
2274 For instance within the
2275 :class:`~wuttaweb.views.users.UserView` these give the same
2276 result::
2278 self.request.has_perm('users.edit')
2280 self.has_perm('edit')
2282 So this shortcut only applies to permissions defined for the
2283 current master view. The first example above must still be
2284 used to check for "foreign" permissions (i.e. any needing a
2285 different prefix).
2286 """
2287 permission_prefix = self.get_permission_prefix()
2288 return self.request.has_perm(f"{permission_prefix}.{name}")
2290 def has_any_perm(self, *names):
2291 """
2292 Shortcut to check if current user has any of the given
2293 permissions.
2295 This calls :meth:`has_perm()` until one returns ``True``. If
2296 none do, returns ``False``.
2297 """
2298 for name in names:
2299 if self.has_perm(name):
2300 return True
2301 return False
2303 def make_button(
2304 self,
2305 label,
2306 variant=None,
2307 primary=False,
2308 url=None,
2309 **kwargs,
2310 ):
2311 """
2312 Make and return a HTML ``<b-button>`` literal.
2314 :param label: Text label for the button.
2316 :param variant: This is the "Buefy type" (or "Oruga variant")
2317 for the button. Buefy and Oruga represent this differently
2318 but this logic expects the Buefy format
2319 (e.g. ``is-danger``) and *not* the Oruga format
2320 (e.g. ``danger``), despite the param name matching Oruga's
2321 terminology.
2323 :param type: This param is not advertised in the method
2324 signature, but if caller specifies ``type`` instead of
2325 ``variant`` it should work the same.
2327 :param primary: If neither ``variant`` nor ``type`` are
2328 specified, this flag may be used to automatically set the
2329 Buefy type to ``is-primary``.
2331 This is the preferred method where applicable, since it
2332 avoids the Buefy vs. Oruga confusion, and the
2333 implementation can change in the future.
2335 :param url: Specify this (instead of ``href``) to make the
2336 button act like a link. This will yield something like:
2337 ``<b-button tag="a" href="{url}">``
2339 :param \\**kwargs: All remaining kwargs are passed to the
2340 underlying ``HTML.tag()`` call, so will be rendered as
2341 attributes on the button tag.
2343 **NB.** You cannot specify a ``tag`` kwarg, for technical
2344 reasons.
2346 :returns: HTML literal for the button element. Will be something
2347 along the lines of:
2349 .. code-block::
2351 <b-button type="is-primary"
2352 icon-pack="fas"
2353 icon-left="hand-pointer">
2354 Click Me
2355 </b-button>
2356 """
2357 btn_kw = kwargs
2358 btn_kw.setdefault("c", label)
2359 btn_kw.setdefault("icon_pack", "fas")
2361 if "type" not in btn_kw:
2362 if variant:
2363 btn_kw["type"] = variant
2364 elif primary:
2365 btn_kw["type"] = "is-primary"
2367 if url:
2368 btn_kw["href"] = url
2370 button = HTML.tag("b-button", **btn_kw)
2372 if url:
2373 # nb. unfortunately HTML.tag() calls its first arg 'tag'
2374 # and so we can't pass a kwarg with that name...so instead
2375 # we patch that into place manually
2376 button = str(button)
2377 button = button.replace("<b-button ", '<b-button tag="a" ')
2378 button = HTML.literal(button)
2380 return button
2382 def get_xref_buttons(self, obj): # pylint: disable=unused-argument
2383 """
2384 Should return a list of "cross-reference" buttons to be shown
2385 when viewing the given object.
2387 Default logic always returns empty list; subclass can override
2388 as needed.
2390 If applicable, this method should do its own permission checks
2391 and only include the buttons current user should be allowed to
2392 see/use.
2394 See also :meth:`make_button()` - example::
2396 def get_xref_buttons(self, product):
2397 buttons = []
2398 if self.request.has_perm('external_products.view'):
2399 url = self.request.route_url('external_products.view',
2400 id=product.external_id)
2401 buttons.append(self.make_button("View External", url=url))
2402 return buttons
2403 """
2404 return []
2406 def make_progress(self, key, **kwargs):
2407 """
2408 Create and return a
2409 :class:`~wuttaweb.progress.SessionProgress` instance, with the
2410 given key.
2412 This is normally done just before calling
2413 :meth:`render_progress()`.
2414 """
2415 return SessionProgress(self.request, key, **kwargs)
2417 def render_progress(self, progress, context=None, template=None):
2418 """
2419 Render the progress page, with given template/context.
2421 When a view method needs to start a long-running operation, it
2422 first starts a thread to do the work, and then it renders the
2423 "progress" page. As the operation continues the progress page
2424 is updated. When the operation completes (or fails) the user
2425 is redirected to the final destination.
2427 TODO: should document more about how to do this..
2429 :param progress: Progress indicator instance as returned by
2430 :meth:`make_progress()`.
2432 :returns: A :term:`response` with rendered progress page.
2433 """
2434 template = template or "/progress.mako"
2435 context = context or {}
2436 context["progress"] = progress
2437 return render_to_response(template, context, request=self.request)
2439 def render_to_response(self, template, context):
2440 """
2441 Locate and render an appropriate template, with the given
2442 context, and return a :term:`response`.
2444 The specified ``template`` should be only the "base name" for
2445 the template - e.g. ``'index'`` or ``'edit'``. This method
2446 will then try to locate a suitable template file, based on
2447 values from :meth:`get_template_prefix()` and
2448 :meth:`get_fallback_templates()`.
2450 In practice this *usually* means two different template paths
2451 will be attempted, e.g. if ``template`` is ``'edit'`` and
2452 :attr:`template_prefix` is ``'/widgets'``:
2454 * ``/widgets/edit.mako``
2455 * ``/master/edit.mako``
2457 The first template found to exist will be used for rendering.
2458 It then calls
2459 :func:`pyramid:pyramid.renderers.render_to_response()` and
2460 returns the result.
2462 :param template: Base name for the template.
2464 :param context: Data dict to be used as template context.
2466 :returns: Response object containing the rendered template.
2467 """
2468 defaults = {
2469 "master": self,
2470 "route_prefix": self.get_route_prefix(),
2471 "index_title": self.get_index_title(),
2472 "index_url": self.get_index_url(),
2473 "model_title": self.get_model_title(),
2474 "model_title_plural": self.get_model_title_plural(),
2475 "config_title": self.get_config_title(),
2476 }
2478 # merge defaults + caller-provided context
2479 defaults.update(context)
2480 context = defaults
2482 # add crud flags if we have an instance
2483 if "instance" in context:
2484 instance = context["instance"]
2485 if "instance_title" not in context:
2486 context["instance_title"] = self.get_instance_title(instance)
2487 if "instance_editable" not in context:
2488 context["instance_editable"] = self.is_editable(instance)
2489 if "instance_deletable" not in context:
2490 context["instance_deletable"] = self.is_deletable(instance)
2492 # supplement context further if needed
2493 context = self.get_template_context(context)
2495 # first try the template path most specific to this view
2496 page_templates = self.get_page_templates(template)
2497 mako_path = page_templates[0]
2498 try:
2499 return render_to_response(mako_path, context, request=self.request)
2500 except IOError:
2502 # failing that, try one or more fallback templates
2503 for fallback in page_templates[1:]:
2504 try:
2505 return render_to_response(fallback, context, request=self.request)
2506 except IOError:
2507 pass
2509 # if we made it all the way here, then we found no
2510 # templates at all, in which case re-attempt the first and
2511 # let that error raise on up
2512 return render_to_response(mako_path, context, request=self.request)
2514 def get_template_context(self, context):
2515 """
2516 This method should return the "complete" context for rendering
2517 the current view template.
2519 Default logic for this method returns the given context
2520 unchanged.
2522 You may wish to override to pass extra context to the view
2523 template. Check :attr:`viewing` and similar, or
2524 ``request.current_route_name`` etc. in order to add extra
2525 context only for certain view templates.
2527 :params: context: The context dict we have so far,
2528 auto-provided by the master view logic.
2530 :returns: Final context dict for the template.
2531 """
2532 return context
2534 def get_page_templates(self, template):
2535 """
2536 Returns a list of all templates which can be attempted, to
2537 render the current page. This is called by
2538 :meth:`render_to_response()`.
2540 The list should be in order of preference, e.g. the first
2541 entry will be the most "specific" template, with subsequent
2542 entries becoming more generic.
2544 In practice this method defines the first entry but calls
2545 :meth:`get_fallback_templates()` for the rest.
2547 :param template: Base name for a template (without prefix), e.g.
2548 ``'view'``.
2550 :returns: List of template paths to be tried, based on the
2551 specified template. For instance if ``template`` is
2552 ``'view'`` this will (by default) return::
2554 [
2555 '/widgets/view.mako',
2556 '/master/view.mako',
2557 ]
2559 """
2560 template_prefix = self.get_template_prefix()
2561 page_templates = [f"{template_prefix}/{template}.mako"]
2562 page_templates.extend(self.get_fallback_templates(template))
2563 return page_templates
2565 def get_fallback_templates(self, template):
2566 """
2567 Returns a list of "fallback" template paths which may be
2568 attempted for rendering the current page. See also
2569 :meth:`get_page_templates()`.
2571 :param template: Base name for a template (without prefix), e.g.
2572 ``'view'``.
2574 :returns: List of template paths to be tried, based on the
2575 specified template. For instance if ``template`` is
2576 ``'view'`` this will (by default) return::
2578 ['/master/view.mako']
2579 """
2580 return [f"/master/{template}.mako"]
2582 def get_index_title(self):
2583 """
2584 Returns the main index title for the master view.
2586 By default this returns the value from
2587 :meth:`get_model_title_plural()`. Subclass may override as
2588 needed.
2589 """
2590 return self.get_model_title_plural()
2592 def get_index_url(self, **kwargs):
2593 """
2594 Returns the URL for master's :meth:`index()` view.
2596 NB. this returns ``None`` if :attr:`listable` is false.
2597 """
2598 if self.listable:
2599 route_prefix = self.get_route_prefix()
2600 return self.request.route_url(route_prefix, **kwargs)
2601 return None
2603 def set_labels(self, obj):
2604 """
2605 Set label overrides on a form or grid, based on what is
2606 defined by the view class and its parent class(es).
2608 This is called automatically from :meth:`configure_grid()` and
2609 :meth:`configure_form()`.
2611 This calls :meth:`collect_labels()` to find everything, then
2612 it assigns the labels using one of (based on ``obj`` type):
2614 * :func:`wuttaweb.forms.base.Form.set_label()`
2615 * :func:`wuttaweb.grids.base.Grid.set_label()`
2617 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
2618 :class:`~wuttaweb.forms.base.Form` instance.
2619 """
2620 labels = self.collect_labels()
2621 for key, label in labels.items():
2622 obj.set_label(key, label)
2624 def collect_labels(self):
2625 """
2626 Collect all labels defined by the view class and/or its parents.
2628 A master view can declare labels via class-level attribute,
2629 like so::
2631 from wuttaweb.views import MasterView
2633 class WidgetView(MasterView):
2635 labels = {
2636 'id': "Widget ID",
2637 'serial_no': "Serial Number",
2638 }
2640 All such labels, defined by any class from which the master
2641 view inherits, will be returned. However if the same label
2642 key is defined by multiple classes, the "subclass" always
2643 wins.
2645 Labels defined in this way will apply to both forms and grids.
2646 See also :meth:`set_labels()`.
2648 :returns: Dict of all labels found.
2649 """
2650 labels = {}
2651 hierarchy = self.get_class_hierarchy()
2652 for cls in hierarchy:
2653 if hasattr(cls, "labels"):
2654 labels.update(cls.labels)
2655 return labels
2657 def make_model_grid(
2658 self, session=None, **kwargs
2659 ): # pylint: disable=too-many-branches,too-many-statements
2660 """
2661 Create and return a :class:`~wuttaweb.grids.base.Grid`
2662 instance for use with the :meth:`index()` view.
2664 See also related methods, which are called by this one:
2666 * :meth:`get_grid_key()`
2667 * :meth:`get_grid_columns()`
2668 * :meth:`get_grid_data()`
2669 * :meth:`configure_grid()`
2670 """
2671 route_prefix = self.get_route_prefix()
2673 if "key" not in kwargs:
2674 kwargs["key"] = self.get_grid_key()
2676 if "model_class" not in kwargs:
2677 model_class = self.get_model_class()
2678 if model_class:
2679 kwargs["model_class"] = model_class
2681 if "columns" not in kwargs:
2682 kwargs["columns"] = self.get_grid_columns()
2684 if "data" not in kwargs:
2685 kwargs["data"] = self.get_grid_data(
2686 columns=kwargs["columns"], session=session
2687 )
2689 if "actions" not in kwargs:
2690 actions = []
2692 # TODO: should split this off into index_get_grid_actions() ?
2694 if self.viewable and self.has_perm("view"):
2695 actions.append(
2696 self.make_grid_action(
2697 "view", icon="eye", url=self.get_action_url_view
2698 )
2699 )
2701 if self.editable and self.has_perm("edit"):
2702 actions.append(
2703 self.make_grid_action(
2704 "edit", icon="edit", url=self.get_action_url_edit
2705 )
2706 )
2708 if self.deletable and self.has_perm("delete"):
2709 actions.append(
2710 self.make_grid_action(
2711 "delete",
2712 icon="trash",
2713 url=self.get_action_url_delete,
2714 link_class="has-text-danger",
2715 )
2716 )
2718 kwargs["actions"] = actions
2720 mergeable = self.mergeable and self.has_perm("merge")
2722 if "tools" not in kwargs:
2723 tools = []
2725 # show totals
2726 if self.has_grid_totals:
2727 button = self.make_button(
2728 "{{ gridTotalsFetching ? 'Working, please wait...' : 'Show Totals' }}",
2729 icon_left="calculator",
2730 **{
2731 "v-if": "!gridTotalsHTML.length",
2732 ":disabled": "gridTotalsFetching",
2733 "@click": "gridTotalsFetch()",
2734 },
2735 )
2736 display = HTML.tag(
2737 "div",
2738 class_="control",
2739 style="margin: 0 0.5rem;",
2740 **{
2741 "v-if": "gridTotalsHTML.length",
2742 "v-html": "gridTotalsHTML",
2743 },
2744 )
2745 wrapper = HTML.tag("div", c=[button, display])
2746 tools.append(("show-totals", wrapper))
2748 # delete-bulk
2749 if self.deletable_bulk and self.has_perm("delete_bulk"):
2750 tools.append(("delete-results", self.delete_bulk_make_button()))
2752 # merge
2753 if mergeable:
2754 hidden = tags.hidden("uuids", **{":value": "checkedUUIDs"})
2755 button = self.make_button(
2756 '{{ mergeSubmitting ? "Working, please wait..." : "Merge 2 records" }}',
2757 primary=True,
2758 native_type="submit",
2759 icon_left="object-ungroup",
2760 **{":disabled": "mergeSubmitting || checkedRows.length != 2"},
2761 )
2762 csrf = render_csrf_token(self.request)
2763 html = (
2764 tags.form(
2765 self.request.route_url(f"{route_prefix}.merge"),
2766 **{"@submit": "mergeSubmitting = true"},
2767 )
2768 + csrf
2769 + hidden
2770 + button
2771 + tags.end_form()
2772 )
2773 tools.append(("merge", html))
2775 kwargs["tools"] = tools
2777 kwargs.setdefault("checkable", self.checkable or mergeable)
2778 if hasattr(self, "grid_row_class"):
2779 kwargs.setdefault("row_class", self.grid_row_class)
2780 kwargs.setdefault("filterable", self.filterable)
2781 kwargs.setdefault("filter_defaults", self.filter_defaults)
2782 kwargs.setdefault("sortable", self.sortable)
2783 kwargs.setdefault("sort_on_backend", self.sort_on_backend)
2784 kwargs.setdefault("sort_defaults", self.sort_defaults)
2785 kwargs.setdefault("paginated", self.paginated)
2786 kwargs.setdefault("paginate_on_backend", self.paginate_on_backend)
2788 grid = self.make_grid(**kwargs)
2789 self.configure_grid(grid)
2790 grid.load_settings()
2791 return grid
2793 def get_grid_columns(self):
2794 """
2795 Returns the default list of grid column names, for the
2796 :meth:`index()` view.
2798 This is called by :meth:`make_model_grid()`; in the resulting
2799 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2800 :attr:`~wuttaweb.grids.base.Grid.columns`.
2802 This method may return ``None``, in which case the grid may
2803 (try to) generate its own default list.
2805 Subclass may define :attr:`grid_columns` for simple cases, or
2806 can override this method if needed.
2808 Also note that :meth:`configure_grid()` may be used to further
2809 modify the final column set, regardless of what this method
2810 returns. So a common pattern is to declare all "supported"
2811 columns by setting :attr:`grid_columns` but then optionally
2812 remove or replace some of those within
2813 :meth:`configure_grid()`.
2814 """
2815 if hasattr(self, "grid_columns"):
2816 return self.grid_columns
2817 return None
2819 def get_grid_data( # pylint: disable=unused-argument
2820 self, columns=None, session=None
2821 ):
2822 """
2823 Returns the grid data for the :meth:`index()` view.
2825 This is called by :meth:`make_model_grid()`; in the resulting
2826 :class:`~wuttaweb.grids.base.Grid` instance, this becomes
2827 :attr:`~wuttaweb.grids.base.Grid.data`.
2829 Default logic will call :meth:`get_query()` and if successful,
2830 return the list from ``query.all()``. Otherwise returns an
2831 empty list. Subclass should override as needed.
2832 """
2833 query = self.get_query(session=session)
2834 if query:
2835 return query
2836 return []
2838 def get_query(self, session=None):
2839 """
2840 Returns the main SQLAlchemy query object for the
2841 :meth:`index()` view. This is called by
2842 :meth:`get_grid_data()`.
2844 Default logic for this method returns a "plain" query on the
2845 :attr:`model_class` if that is defined; otherwise ``None``.
2846 """
2847 model_class = self.get_model_class()
2848 if model_class:
2849 session = session or self.Session()
2850 return session.query(model_class)
2851 return None
2853 def configure_grid(self, grid):
2854 """
2855 Configure the grid for the :meth:`index()` view.
2857 This is called by :meth:`make_model_grid()`.
2859 There is minimal default logic here; subclass should override
2860 as needed. The ``grid`` param will already be "complete" and
2861 ready to use as-is, but this method can further modify it
2862 based on request details etc.
2863 """
2864 if "uuid" in grid.columns:
2865 grid.columns.remove("uuid")
2867 self.set_labels(grid)
2869 # TODO: i thought this was a good idea but if so it
2870 # needs a try/catch in case of no model class
2871 # for key in self.get_model_key():
2872 # grid.set_link(key)
2874 def get_instance(self, session=None, matchdict=None):
2875 """
2876 This should return the appropriate model instance, based on
2877 the ``matchdict`` of model keys.
2879 Normally this is called with no arguments, in which case the
2880 :attr:`pyramid:pyramid.request.Request.matchdict` is used, and
2881 will return the "current" model instance based on the request
2882 (route/params).
2884 If a ``matchdict`` is provided then that is used instead, to
2885 obtain the model keys. In the simple/common example of a
2886 "native" model in WuttaWeb, this would look like::
2888 keys = {'uuid': '38905440630d11ef9228743af49773a4'}
2889 obj = self.get_instance(matchdict=keys)
2891 Although some models may have different, possibly composite
2892 key names to use instead. The specific keys this logic is
2893 expecting are the same as returned by :meth:`get_model_key()`.
2895 If this method is unable to locate the instance, it should
2896 raise a 404 error,
2897 i.e. :meth:`~wuttaweb.views.base.View.notfound()`.
2899 Default implementation of this method should work okay for
2900 views which define a :attr:`model_class`. For other views
2901 however it will raise ``NotImplementedError``, so subclass
2902 may need to define.
2904 .. warning::
2906 If you are defining this method for a subclass, please note
2907 this point regarding the 404 "not found" logic.
2909 It is *not* enough to simply *return* this 404 response,
2910 you must explicitly *raise* the error. For instance::
2912 def get_instance(self, **kwargs):
2914 # ..try to locate instance..
2915 obj = self.locate_instance_somehow()
2917 if not obj:
2919 # NB. THIS MAY NOT WORK AS EXPECTED
2920 #return self.notfound()
2922 # nb. should always do this in get_instance()
2923 raise self.notfound()
2925 This lets calling code not have to worry about whether or
2926 not this method might return ``None``. It can safely
2927 assume it will get back a model instance, or else a 404
2928 will kick in and control flow goes elsewhere.
2929 """
2930 model_class = self.get_model_class()
2931 if model_class:
2932 session = session or self.Session()
2933 matchdict = matchdict or self.request.matchdict
2935 def filtr(query, model_key):
2936 key = matchdict[model_key]
2937 query = query.filter(getattr(self.model_class, model_key) == key)
2938 return query
2940 query = session.query(model_class)
2942 for key in self.get_model_key():
2943 query = filtr(query, key)
2945 try:
2946 return query.one()
2947 except orm.exc.NoResultFound:
2948 pass
2950 raise self.notfound()
2952 raise NotImplementedError(
2953 "you must define get_instance() method "
2954 f" for view class: {self.__class__}"
2955 )
2957 def get_instance_title(self, instance):
2958 """
2959 Return the human-friendly "title" for the instance, to be used
2960 in the page title when viewing etc.
2962 Default logic returns the value from ``str(instance)``;
2963 subclass may override if needed.
2964 """
2965 return str(instance) or "(no title)"
2967 def get_action_route_kwargs(self, obj):
2968 """
2969 Get a dict of route kwargs for the given object.
2971 This is called from :meth:`get_action_url()` and must return
2972 kwargs suitable for use with ``request.route_url()``.
2974 In practice this should return a dict which has keys for each
2975 field from :meth:`get_model_key()` and values which come from
2976 the object.
2978 :param obj: Model instance object.
2980 :returns: The dict of route kwargs for the object.
2981 """
2982 try:
2983 return {key: obj[key] for key in self.get_model_key()}
2984 except TypeError:
2985 return {key: getattr(obj, key) for key in self.get_model_key()}
2987 def get_action_url(self, action, obj, **kwargs):
2988 """
2989 Generate an "action" URL for the given model instance.
2991 This is a shortcut which generates a route name based on
2992 :meth:`get_route_prefix()` and the ``action`` param.
2994 It calls :meth:`get_action_route_kwargs()` and then passes
2995 those along with route name to ``request.route_url()``, and
2996 returns the result.
2998 :param action: String name for the action, which corresponds
2999 to part of some named route, e.g. ``'view'`` or ``'edit'``.
3001 :param obj: Model instance object.
3003 :param \\**kwargs: Additional kwargs to be passed to
3004 ``request.route_url()``, if needed.
3005 """
3006 kw = self.get_action_route_kwargs(obj)
3007 kw.update(kwargs)
3008 route_prefix = self.get_route_prefix()
3009 return self.request.route_url(f"{route_prefix}.{action}", **kw)
3011 def get_action_url_view(self, obj, i): # pylint: disable=unused-argument
3012 """
3013 Returns the "view" grid action URL for the given object.
3015 Most typically this is like ``/widgets/XXX`` where ``XXX``
3016 represents the object's key/ID.
3018 Calls :meth:`get_action_url()` under the hood.
3019 """
3020 return self.get_action_url("view", obj)
3022 def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument
3023 """
3024 Returns the "edit" grid action URL for the given object, if
3025 applicable.
3027 Most typically this is like ``/widgets/XXX/edit`` where
3028 ``XXX`` represents the object's key/ID.
3030 This first calls :meth:`is_editable()` and if that is false,
3031 this method will return ``None``.
3033 Calls :meth:`get_action_url()` to generate the true URL.
3034 """
3035 if self.is_editable(obj):
3036 return self.get_action_url("edit", obj)
3037 return None
3039 def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument
3040 """
3041 Returns the "delete" grid action URL for the given object, if
3042 applicable.
3044 Most typically this is like ``/widgets/XXX/delete`` where
3045 ``XXX`` represents the object's key/ID.
3047 This first calls :meth:`is_deletable()` and if that is false,
3048 this method will return ``None``.
3050 Calls :meth:`get_action_url()` to generate the true URL.
3051 """
3052 if self.is_deletable(obj):
3053 return self.get_action_url("delete", obj)
3054 return None
3056 def is_editable(self, obj): # pylint: disable=unused-argument
3057 """
3058 Returns a boolean indicating whether "edit" should be allowed
3059 for the given model instance (and for current user).
3061 By default this always return ``True``; subclass can override
3062 if needed.
3064 Note that the use of this method implies :attr:`editable` is
3065 true, so the method does not need to check that flag.
3066 """
3067 return True
3069 def is_deletable(self, obj): # pylint: disable=unused-argument
3070 """
3071 Returns a boolean indicating whether "delete" should be
3072 allowed for the given model instance (and for current user).
3074 By default this always return ``True``; subclass can override
3075 if needed.
3077 Note that the use of this method implies :attr:`deletable` is
3078 true, so the method does not need to check that flag.
3079 """
3080 return True
3082 def make_model_form(self, model_instance=None, fields=None, **kwargs):
3083 """
3084 Make a form for the "model" represented by this subclass.
3086 This method is normally called by all CRUD views:
3088 * :meth:`create()`
3089 * :meth:`view()`
3090 * :meth:`edit()`
3091 * :meth:`delete()`
3093 The form need not have a ``model_instance``, as in the case of
3094 :meth:`create()`. And it can be readonly as in the case of
3095 :meth:`view()` and :meth:`delete()`.
3097 If ``fields`` are not provided, :meth:`get_form_fields()` is
3098 called. Usually a subclass will define :attr:`form_fields`
3099 but it's only required if :attr:`model_class` is not set.
3101 Then :meth:`configure_form()` is called, so subclass can go
3102 crazy with that as needed.
3104 :param model_instance: Model instance/record with which to
3105 initialize the form data. Not needed for "create" forms.
3107 :param fields: Optional fields list for the form.
3109 :returns: :class:`~wuttaweb.forms.base.Form` instance
3110 """
3111 if "model_class" not in kwargs:
3112 model_class = self.get_model_class()
3113 if model_class:
3114 kwargs["model_class"] = model_class
3116 kwargs["model_instance"] = model_instance
3118 if not fields:
3119 fields = self.get_form_fields()
3120 if fields:
3121 kwargs["fields"] = fields
3123 form = self.make_form(**kwargs)
3124 self.configure_form(form)
3125 return form
3127 def get_form_fields(self):
3128 """
3129 Returns the initial list of field names for the model form.
3131 This is called by :meth:`make_model_form()`; in the resulting
3132 :class:`~wuttaweb.forms.base.Form` instance, this becomes
3133 :attr:`~wuttaweb.forms.base.Form.fields`.
3135 This method may return ``None``, in which case the form may
3136 (try to) generate its own default list.
3138 Subclass may define :attr:`form_fields` for simple cases, or
3139 can override this method if needed.
3141 Note that :meth:`configure_form()` may be used to further
3142 modify the final field list, regardless of what this method
3143 returns. So a common pattern is to declare all "supported"
3144 fields by setting :attr:`form_fields` but then optionally
3145 remove or replace some in :meth:`configure_form()`.
3146 """
3147 if hasattr(self, "form_fields"):
3148 return self.form_fields
3149 return None
3151 def configure_form(self, form):
3152 """
3153 Configure the given model form, as needed.
3155 This is called by :meth:`make_model_form()` - for multiple
3156 CRUD views (create, view, edit, delete, possibly others).
3158 The default logic here does just one thing: when "editing"
3159 (i.e. in :meth:`edit()` view) then all fields which are part
3160 of the :attr:`model_key` will be marked via
3161 :meth:`set_readonly()` so the user cannot change primary key
3162 values for a record.
3164 Subclass may override as needed. The ``form`` param will
3165 already be "complete" and ready to use as-is, but this method
3166 can further modify it based on request details etc.
3167 """
3168 form.remove("uuid")
3170 self.set_labels(form)
3172 # mark key fields as readonly to prevent edit. see also
3173 # related comments in the objectify() method
3174 if self.editing:
3175 for key in self.get_model_key():
3176 form.set_readonly(key)
3178 def objectify(self, form):
3179 """
3180 Must return a "model instance" object which reflects the
3181 validated form data.
3183 In simple cases this may just return the
3184 :attr:`~wuttaweb.forms.base.Form.validated` data dict.
3186 When dealing with SQLAlchemy models it would return a proper
3187 mapped instance, creating it if necessary.
3189 This is called by various other form-saving methods:
3191 * :meth:`save_create_form()`
3192 * :meth:`save_edit_form()`
3193 * :meth:`create_row_save_form()`
3195 See also :meth:`persist()`.
3197 :param form: Reference to the *already validated*
3198 :class:`~wuttaweb.forms.base.Form` object. See the form's
3199 :attr:`~wuttaweb.forms.base.Form.validated` attribute for
3200 the data.
3201 """
3203 # ColanderAlchemy schema has an objectify() method which will
3204 # return a populated model instance
3205 schema = form.get_schema()
3206 if hasattr(schema, "objectify"):
3207 return schema.objectify(form.validated, context=form.model_instance)
3209 # at this point we likely have no model class, so have to
3210 # assume we're operating on a simple dict record. we (mostly)
3211 # want to return that as-is, unless subclass overrides.
3212 data = dict(form.validated)
3214 # nb. we have a unique scenario when *editing* for a simple
3215 # dict record (no model class). we mark the key fields as
3216 # readonly in configure_form(), so they aren't part of the
3217 # data here, but we need to add them back for sake of
3218 # e.g. generating the 'view' route kwargs for redirect.
3219 if self.editing:
3220 obj = self.get_instance()
3221 for key in self.get_model_key():
3222 if key not in data:
3223 data[key] = obj[key]
3225 return data
3227 def persist(self, obj, session=None):
3228 """
3229 If applicable, this method should persist ("save") the given
3230 object's data (e.g. to DB), creating or updating it as needed.
3232 This is part of the "submit form" workflow; ``obj`` should be
3233 a model instance which already reflects the validated form
3234 data.
3236 Note that there is no default logic here, subclass must
3237 override if needed.
3239 :param obj: Model instance object as produced by
3240 :meth:`objectify()`.
3242 See also :meth:`save_create_form()` and
3243 :meth:`save_edit_form()`, which call this method.
3244 """
3245 model = self.app.model
3246 model_class = self.get_model_class()
3247 if model_class and issubclass(model_class, model.Base):
3249 # add sqlalchemy model to session
3250 session = session or self.Session()
3251 session.add(obj)
3253 def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments
3254 self, func, args, kwargs, onerror=None, session=None, progress=None
3255 ):
3256 """
3257 Generic method to invoke for thread operations.
3259 :param func: Callable which performs the actual logic. This
3260 will be wrapped with a try/except statement for error
3261 handling.
3263 :param args: Tuple of positional arguments to pass to the
3264 ``func`` callable.
3266 :param kwargs: Dict of keyword arguments to pass to the
3267 ``func`` callable.
3269 :param onerror: Optional callback to invoke if ``func`` raises
3270 an error. It should not expect any arguments.
3272 :param session: Optional :term:`db session` in effect. Note
3273 that if supplied, it will be *committed* (or rolled back on
3274 error) and *closed* by this method. If you need more
3275 specialized handling, do not use this method (or don't
3276 specify the ``session``).
3278 :param progress: Optional progress factory. If supplied, this
3279 is assumed to be a
3280 :class:`~wuttaweb.progress.SessionProgress` instance, and
3281 it will be updated per success or failure of ``func``
3282 invocation.
3283 """
3284 try:
3285 func(*args, **kwargs)
3287 except Exception as error: # pylint: disable=broad-exception-caught
3288 if session:
3289 session.rollback()
3290 if onerror:
3291 onerror()
3292 else:
3293 log.warning("failed to invoke thread callable: %s", func, exc_info=True)
3294 if progress:
3295 progress.handle_error(error)
3297 else:
3298 if session:
3299 session.commit()
3300 if progress:
3301 progress.handle_success()
3303 finally:
3304 if session:
3305 session.close()
3307 ##############################
3308 # merge methods
3309 ##############################
3311 def merge(self):
3312 """
3313 View for merging two records.
3315 By default, this view is included only if :attr:`mergeable` is
3316 true. It usually maps to a URL like ``/widgets/merge``.
3318 A POST request must be used for this view; otherwise it will
3319 redirect to the :meth:`index()` view. The POST data must
3320 specify a ``uuids`` param string in
3321 ``"removing_uuid,keeping_uuid"`` format.
3323 The user is first shown a "diff" with the
3324 removing/keeping/final data records, as simple preview. They
3325 can swap removing vs. keeping if needed, and when satisfied
3326 they can "execute" the merge.
3328 See also related methods, used by this one:
3330 * :meth:`merge_validate_and_execute()`
3331 * :meth:`merge_get_data()`
3332 * :meth:`merge_get_final_data()`
3333 """
3334 if self.request.method != "POST":
3335 return self.redirect(self.get_index_url())
3337 session = self.Session()
3338 model_class = self.get_model_class()
3340 # load records to be kept/removed
3341 removing = keeping = None
3342 uuids = self.request.POST.get("uuids", "").split(",")
3343 if len(uuids) == 2:
3344 uuid1, uuid2 = uuids
3345 try:
3346 uuid1 = UUID(uuid1)
3347 uuid2 = UUID(uuid2)
3348 except ValueError:
3349 pass
3350 else:
3351 removing = session.get(model_class, uuid1)
3352 keeping = session.get(model_class, uuid2)
3354 # redirect to listing if record(s) not found
3355 if not (removing and keeping):
3356 raise self.redirect(self.get_index_url())
3358 # maybe execute merge
3359 if self.request.POST.get("execute-merge") == "true":
3360 if self.merge_validate_and_execute(removing, keeping):
3361 return self.redirect(self.get_action_url("view", keeping))
3363 removing_data = self.merge_get_data(removing)
3364 keeping_data = self.merge_get_data(keeping)
3365 diff = MergeDiff(
3366 self.config,
3367 removing_data,
3368 keeping_data,
3369 self.merge_get_final_data(removing_data, keeping_data),
3370 fields=self.merge_get_all_fields(),
3371 )
3373 context = {"removing": removing, "keeping": keeping, "diff": diff}
3374 return self.render_to_response("merge", context)
3376 def merge_get_simple_fields(self):
3377 """
3378 Return the list of "simple" fields for the merge.
3380 These "simple" fields will not have any special handling for
3381 the merge. In other words the "removing" record values will
3382 be ignored and the "keeping" record values will remain in
3383 place, without modification.
3385 If the view class defines :attr:`merge_simple_fields`, that
3386 list is returned as-is. Otherwise the list of columns from
3387 :attr:`model_class` is returned.
3389 :returns: List of simple field names.
3390 """
3391 if self.merge_simple_fields:
3392 return list(self.merge_simple_fields)
3394 mapper = sa.inspect(self.get_model_class())
3395 fields = mapper.columns.keys()
3396 return fields
3398 def merge_get_additive_fields(self):
3399 """
3400 Return the list of "additive" fields for the merge.
3402 Values from the removing/keeping record will be conceptually
3403 added together, for each of these fields.
3405 If the view class defines :attr:`merge_additive_fields`, that
3406 list is returned as-is. Otherwise an empty list is returned.
3408 :returns: List of additive field names.
3409 """
3410 if self.merge_additive_fields:
3411 return list(self.merge_additive_fields)
3412 return []
3414 def merge_get_coalesce_fields(self):
3415 """
3416 Return the list of "coalesce" fields for the merge.
3418 Values from the removing/keeping record will be conceptually
3419 "coalesced" for each of these fields.
3421 If the view class defines :attr:`merge_coalesce_fields`, that
3422 list is returned as-is. Otherwise an empty list is returned.
3424 :returns: List of coalesce field names.
3425 """
3426 if self.merge_coalesce_fields:
3427 return list(self.merge_coalesce_fields)
3428 return []
3430 def merge_get_all_fields(self):
3431 """
3432 Return the list of *all* fields for the merge.
3434 This will call each of the following methods to collect all
3435 field names, then it returns the full *sorted* list.
3437 * :meth:`merge_get_additive_fields()`
3438 * :meth:`merge_get_coalesce_fields()`
3439 * :meth:`merge_get_simple_fields()`
3441 :returns: Sorted list of all field names.
3442 """
3443 fields = set()
3444 fields.update(self.merge_get_simple_fields())
3445 fields.update(self.merge_get_additive_fields())
3446 fields.update(self.merge_get_coalesce_fields())
3447 return sorted(fields)
3449 def merge_get_data(self, obj):
3450 """
3451 Return a data dict for the given object, which will be either
3452 the "removing" or "keeping" record for the merge.
3454 By default this calls :meth:`merge_get_all_fields()` and then
3455 for each field, calls ``getattr()`` on the object. Subclass
3456 can override as needed for custom logic.
3458 :param obj: Reference to model/record instance.
3460 :returns: Data dict with all field values.
3461 """
3462 return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()}
3464 def merge_get_final_data(self, removing, keeping):
3465 """
3466 Return the "final" data dict for the merge.
3468 The result will be identical to the "keeping" record, for all
3469 "simple" fields. However the "additive" and "coalesce" fields
3470 are handled specially per their nature, in which case those
3471 final values may or may not match the "keeping" record.
3473 :param removing: Data dict for the "removing" record.
3475 :param keeping: Data dict for the "keeping" record.
3477 :returns: Data dict with all "final" field values.
3479 See also:
3481 * :meth:`merge()`
3482 * :meth:`merge_get_additive_fields()`
3483 * :meth:`merge_get_coalesce_fields()`
3484 """
3485 final = dict(keeping)
3487 for field in self.merge_get_additive_fields():
3488 if isinstance(keeping[field], list):
3489 final[field] = sorted(set(removing[field] + keeping[field]))
3490 else:
3491 final[field] = removing[field] + keeping[field]
3493 for field in self.merge_get_coalesce_fields():
3494 if removing[field] is not None and keeping[field] is None:
3495 final[field] = removing[field]
3496 elif removing[field] and not keeping[field]:
3497 final[field] = removing[field]
3499 return final
3501 def merge_validate_and_execute(self, removing, keeping):
3502 """
3503 Validate and execute a merge for the two given records. It is
3504 called from :meth:`merge()`.
3506 This calls :meth:`merge_why_not()` and if that does not yield
3507 a reason to prevent the merge, then calls
3508 :meth:`merge_execute()`.
3510 If there was a reason not to merge, or if an error occurs
3511 during merge execution, a flash warning/error message is set
3512 to notify the user what happened.
3514 :param removing: Reference to the "removing" model instance/record.
3516 :param keeping: Reference to the "keeping" model instance/record.
3518 :returns: Boolean indicating whether merge execution completed
3519 successfully.
3520 """
3521 session = self.Session()
3523 # validate the merge
3524 if reason := self.merge_why_not(removing, keeping):
3525 warning = HTML.tag(
3526 "p", class_="block", c="Merge cannot proceed:"
3527 ) + HTML.tag("p", class_="block", c=reason)
3528 self.request.session.flash(warning, "warning")
3529 return False
3531 # execute the merge
3532 removed_title = str(removing)
3533 try:
3534 self.merge_execute(removing, keeping)
3535 session.flush()
3536 except Exception as err: # pylint: disable=broad-exception-caught
3537 session.rollback()
3538 log.warning("merge failed", exc_info=True)
3539 warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag(
3540 "p", class_="block", c=self.app.render_error(err)
3541 )
3542 self.request.session.flash(warning, "error")
3543 return False
3545 self.request.session.flash(f"{removed_title} has been merged into {keeping}")
3546 return True
3548 def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument
3549 """
3550 Can return a "reason" why the two given records should not be merged.
3552 This returns ``None`` by default, indicating the merge is
3553 allowed. Subclass can override as needed for custom logic.
3555 See also :meth:`merge_validate_and_execute()`.
3557 :param removing: Reference to the "removing" model instance/record.
3559 :param keeping: Reference to the "keeping" model instance/record.
3561 :returns: Reason not to merge (as string), or ``None``.
3562 """
3563 return None
3565 def merge_execute(self, removing, keeping): # pylint: disable=unused-argument
3566 """
3567 Execute the actual merge for the two given objects.
3569 Default logic simply deletes the "removing" record. Subclass
3570 can override as needed for custom logic.
3572 See also :meth:`merge_validate_and_execute()`.
3574 :param removing: Reference to the "removing" model instance/record.
3576 :param keeping: Reference to the "keeping" model instance/record.
3577 """
3578 session = self.Session()
3580 # nb. default "merge" does not update kept object!
3581 session.delete(removing)
3583 ##############################
3584 # row methods
3585 ##############################
3587 def get_rows_title(self):
3588 """
3589 Returns the display title for model **rows** grid, if
3590 applicable/desired. Only relevant if :attr:`has_rows` is
3591 true.
3593 There is no default here, but subclass may override by
3594 assigning :attr:`rows_title`.
3595 """
3596 if hasattr(self, "rows_title"):
3597 return self.rows_title
3598 return self.get_row_model_title_plural()
3600 def get_row_parent(self, row):
3601 """
3602 This must return the parent object for the given child row.
3603 Only relevant if :attr:`has_rows` is true.
3605 Default logic is not implemented; subclass must override.
3606 """
3607 raise NotImplementedError
3609 def make_row_model_grid(self, obj, **kwargs):
3610 """
3611 Create and return a grid for a record's **rows** data, for use
3612 in :meth:`view()`. Only applicable if :attr:`has_rows` is
3613 true.
3615 :param obj: Current model instance for which rows data is
3616 being displayed.
3618 :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
3619 rows data.
3621 See also related methods, which are called by this one:
3623 * :meth:`get_row_grid_key()`
3624 * :meth:`get_row_grid_columns()`
3625 * :meth:`get_row_grid_data()`
3626 * :meth:`configure_row_grid()`
3627 """
3628 if "key" not in kwargs:
3629 kwargs["key"] = self.get_row_grid_key()
3631 if "model_class" not in kwargs:
3632 model_class = self.get_row_model_class()
3633 if model_class:
3634 kwargs["model_class"] = model_class
3636 if "columns" not in kwargs:
3637 kwargs["columns"] = self.get_row_grid_columns()
3639 if "data" not in kwargs:
3640 kwargs["data"] = self.get_row_grid_data(obj)
3642 kwargs.setdefault("filterable", self.rows_filterable)
3643 kwargs.setdefault("filter_defaults", self.rows_filter_defaults)
3644 kwargs.setdefault("sortable", self.rows_sortable)
3645 kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend)
3646 kwargs.setdefault("sort_defaults", self.rows_sort_defaults)
3647 kwargs.setdefault("paginated", self.rows_paginated)
3648 kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend)
3650 if "actions" not in kwargs:
3651 actions = []
3653 if self.rows_viewable:
3654 actions.append(
3655 self.make_grid_action(
3656 "view", icon="eye", url=self.get_row_action_url_view
3657 )
3658 )
3660 if actions:
3661 kwargs["actions"] = actions
3663 grid = self.make_grid(**kwargs)
3664 self.configure_row_grid(grid)
3665 grid.load_settings()
3666 return grid
3668 def get_row_grid_key(self):
3669 """
3670 Returns the (presumably) unique key to be used for the
3671 **rows** grid in :meth:`view()`. Only relevant if
3672 :attr:`has_rows` is true.
3674 This is called from :meth:`make_row_model_grid()`; in the
3675 resulting grid, this becomes
3676 :attr:`~wuttaweb.grids.base.Grid.key`.
3678 Whereas you can define :attr:`grid_key` for the main grid, the
3679 row grid key is always generated dynamically. This
3680 incorporates the current record key (whose rows are in the
3681 grid) so that the rows grid for each record is unique.
3682 """
3683 parts = [self.get_grid_key()]
3684 for key in self.get_model_key():
3685 parts.append(str(self.request.matchdict[key]))
3686 return ".".join(parts)
3688 def get_row_grid_columns(self):
3689 """
3690 Returns the default list of column names for the **rows**
3691 grid, for use in :meth:`view()`. Only relevant if
3692 :attr:`has_rows` is true.
3694 This is called by :meth:`make_row_model_grid()`; in the
3695 resulting grid, this becomes
3696 :attr:`~wuttaweb.grids.base.Grid.columns`.
3698 This method may return ``None``, in which case the grid may
3699 (try to) generate its own default list.
3701 Subclass may define :attr:`row_grid_columns` for simple cases,
3702 or can override this method if needed.
3704 Also note that :meth:`configure_row_grid()` may be used to
3705 further modify the final column set, regardless of what this
3706 method returns. So a common pattern is to declare all
3707 "supported" columns by setting :attr:`row_grid_columns` but
3708 then optionally remove or replace some of those within
3709 :meth:`configure_row_grid()`.
3710 """
3711 if hasattr(self, "row_grid_columns"):
3712 return self.row_grid_columns
3713 return None
3715 def get_row_grid_data(self, obj):
3716 """
3717 Returns the data for the **rows** grid, for use in
3718 :meth:`view()`. Only relevant if :attr:`has_rows` is true.
3720 This is called by :meth:`make_row_model_grid()`; in the
3721 resulting grid, this becomes
3722 :attr:`~wuttaweb.grids.base.Grid.data`.
3724 Default logic not implemented; subclass must define this.
3725 """
3726 raise NotImplementedError
3728 def configure_row_grid(self, grid):
3729 """
3730 Configure the **rows** grid for use in :meth:`view()`. Only
3731 relevant if :attr:`has_rows` is true.
3733 This is called by :meth:`make_row_model_grid()`.
3735 There is minimal default logic here; subclass should override
3736 as needed. The ``grid`` param will already be "complete" and
3737 ready to use as-is, but this method can further modify it
3738 based on request details etc.
3739 """
3740 grid.remove("uuid")
3741 self.set_row_labels(grid)
3743 def set_row_labels(self, obj):
3744 """
3745 Set label overrides on a **row** form or grid, based on what
3746 is defined by the view class and its parent class(es).
3748 This is called automatically from
3749 :meth:`configure_row_grid()` and
3750 :meth:`configure_row_form()`.
3752 This calls :meth:`collect_row_labels()` to find everything,
3753 then it assigns the labels using one of (based on ``obj``
3754 type):
3756 * :func:`wuttaweb.forms.base.Form.set_label()`
3757 * :func:`wuttaweb.grids.base.Grid.set_label()`
3759 :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
3760 :class:`~wuttaweb.forms.base.Form` instance.
3761 """
3762 labels = self.collect_row_labels()
3763 for key, label in labels.items():
3764 obj.set_label(key, label)
3766 def collect_row_labels(self):
3767 """
3768 Collect all **row** labels defined within the view class
3769 hierarchy.
3771 This is called by :meth:`set_row_labels()`.
3773 :returns: Dict of all labels found.
3774 """
3775 labels = {}
3776 hierarchy = self.get_class_hierarchy()
3777 for cls in hierarchy:
3778 if hasattr(cls, "row_labels"):
3779 labels.update(cls.row_labels)
3780 return labels
3782 def get_row_action_url_view(self, row, i):
3783 """
3784 Must return the "view" action url for the given row object.
3786 Only relevant if :attr:`rows_viewable` is true.
3788 There is no default logic; subclass must override if needed.
3789 """
3790 raise NotImplementedError
3792 def create_row(self):
3793 """
3794 View to create a new "child row" record.
3796 This usually corresponds to a URL like ``/widgets/XXX/new-row``.
3798 By default, this view is included only if
3799 :attr:`rows_creatable` is true.
3801 The default "create row" view logic will show a form with
3802 field widgets, allowing user to submit new values which are
3803 then persisted to the DB (assuming typical SQLAlchemy model).
3805 Subclass normally should not override this method, but rather
3806 one of the related methods which are called (in)directly by
3807 this one:
3809 * :meth:`make_row_model_form()`
3810 * :meth:`configure_row_form()`
3811 * :meth:`create_row_save_form()`
3812 * :meth:`redirect_after_create_row()`
3813 """
3814 self.creating = True
3815 parent = self.get_instance()
3816 parent_url = self.get_action_url("view", parent)
3818 form = self.make_row_model_form(cancel_url_fallback=parent_url)
3819 if form.validate():
3820 result = self.create_row_save_form(form)
3821 return self.redirect_after_create_row(result)
3823 index_link = tags.link_to(self.get_index_title(), self.get_index_url())
3824 parent_link = tags.link_to(self.get_instance_title(parent), parent_url)
3825 index_title_rendered = HTML.literal("<span> »</span>").join(
3826 [index_link, parent_link]
3827 )
3829 context = {
3830 "form": form,
3831 "index_title_rendered": index_title_rendered,
3832 "row_model_title": self.get_row_model_title(),
3833 }
3834 return self.render_to_response("create_row", context)
3836 def create_row_save_form(self, form):
3837 """
3838 This method converts the validated form data to a row model
3839 instance, and then saves the result to DB. It is called by
3840 :meth:`create_row()`.
3842 :returns: The resulting row model instance, as produced by
3843 :meth:`objectify()`.
3844 """
3845 row = self.objectify(form)
3846 session = self.Session()
3847 session.add(row)
3848 session.flush()
3849 return row
3851 def redirect_after_create_row(self, row):
3852 """
3853 Returns a redirect to the "view parent" page relative to the
3854 given newly-created row. Subclass may override as needed.
3856 This is called by :meth:`create_row()`.
3857 """
3858 parent = self.get_row_parent(row)
3859 return self.redirect(self.get_action_url("view", parent))
3861 def make_row_model_form(self, model_instance=None, **kwargs):
3862 """
3863 Create and return a form for the row model.
3865 This is called by :meth:`create_row()`.
3867 See also related methods, which are called by this one:
3869 * :meth:`get_row_model_class()`
3870 * :meth:`get_row_form_fields()`
3871 * :meth:`~wuttaweb.views.base.View.make_form()`
3872 * :meth:`configure_row_form()`
3874 :returns: :class:`~wuttaweb.forms.base.Form` instance
3875 """
3876 if "model_class" not in kwargs:
3877 model_class = self.get_row_model_class()
3878 if model_class:
3879 kwargs["model_class"] = model_class
3881 kwargs["model_instance"] = model_instance
3883 if not kwargs.get("fields"):
3884 fields = self.get_row_form_fields()
3885 if fields:
3886 kwargs["fields"] = fields
3888 form = self.make_form(**kwargs)
3889 self.configure_row_form(form)
3890 return form
3892 def get_row_form_fields(self):
3893 """
3894 Returns the initial list of field names for the row model
3895 form.
3897 This is called by :meth:`make_row_model_form()`; in the
3898 resulting :class:`~wuttaweb.forms.base.Form` instance, this
3899 becomes :attr:`~wuttaweb.forms.base.Form.fields`.
3901 This method may return ``None``, in which case the form may
3902 (try to) generate its own default list.
3904 Subclass may define :attr:`row_form_fields` for simple cases,
3905 or can override this method if needed.
3907 Note that :meth:`configure_row_form()` may be used to further
3908 modify the final field list, regardless of what this method
3909 returns. So a common pattern is to declare all "supported"
3910 fields by setting :attr:`row_form_fields` but then optionally
3911 remove or replace some in :meth:`configure_row_form()`.
3912 """
3913 if hasattr(self, "row_form_fields"):
3914 return self.row_form_fields
3915 return None
3917 def configure_row_form(self, form):
3918 """
3919 Configure the row model form.
3921 This is called by :meth:`make_row_model_form()` - for multiple
3922 CRUD views (create, view, edit, delete, possibly others).
3924 The ``form`` param will already be "complete" and ready to use
3925 as-is, but this method can further modify it based on request
3926 details etc.
3928 Subclass can override as needed, although be sure to invoke
3929 this parent method via ``super()`` if so.
3930 """
3931 form.remove("uuid")
3932 self.set_row_labels(form)
3934 ##############################
3935 # class methods
3936 ##############################
3938 @classmethod
3939 def get_model_class(cls):
3940 """
3941 Returns the model class for the view (if defined).
3943 A model class will *usually* be a SQLAlchemy mapped class,
3944 e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
3946 There is no default value here, but a subclass may override by
3947 assigning :attr:`model_class`.
3949 Note that the model class is not *required* - however if you
3950 do not set the :attr:`model_class`, then you *must* set the
3951 :attr:`model_name`.
3952 """
3953 return cls.model_class
3955 @classmethod
3956 def get_model_name(cls):
3957 """
3958 Returns the model name for the view.
3960 A model name should generally be in the format of a Python
3961 class name, e.g. ``'WuttaWidget'``. (Note this is
3962 *singular*, not plural.)
3964 The default logic will call :meth:`get_model_class()` and
3965 return that class name as-is. A subclass may override by
3966 assigning :attr:`model_name`.
3967 """
3968 if hasattr(cls, "model_name"):
3969 return cls.model_name
3971 return cls.get_model_class().__name__
3973 @classmethod
3974 def get_model_name_normalized(cls):
3975 """
3976 Returns the "normalized" model name for the view.
3978 A normalized model name should generally be in the format of a
3979 Python variable name, e.g. ``'wutta_widget'``. (Note this is
3980 *singular*, not plural.)
3982 The default logic will call :meth:`get_model_name()` and
3983 simply lower-case the result. A subclass may override by
3984 assigning :attr:`model_name_normalized`.
3985 """
3986 if hasattr(cls, "model_name_normalized"):
3987 return cls.model_name_normalized
3989 return cls.get_model_name().lower()
3991 @classmethod
3992 def get_model_title(cls):
3993 """
3994 Returns the "humanized" (singular) model title for the view.
3996 The model title will be displayed to the user, so should have
3997 proper grammar and capitalization, e.g. ``"Wutta Widget"``.
3998 (Note this is *singular*, not plural.)
4000 The default logic will call :meth:`get_model_name()` and use
4001 the result as-is. A subclass may override by assigning
4002 :attr:`model_title`.
4003 """
4004 if hasattr(cls, "model_title"):
4005 return cls.model_title
4007 if model_class := cls.get_model_class():
4008 if hasattr(model_class, "__wutta_hint__"):
4009 if model_title := model_class.__wutta_hint__.get("model_title"):
4010 return model_title
4012 return cls.get_model_name()
4014 @classmethod
4015 def get_model_title_plural(cls):
4016 """
4017 Returns the "humanized" (plural) model title for the view.
4019 The model title will be displayed to the user, so should have
4020 proper grammar and capitalization, e.g. ``"Wutta Widgets"``.
4021 (Note this is *plural*, not singular.)
4023 The default logic will call :meth:`get_model_title()` and
4024 simply add a ``'s'`` to the end. A subclass may override by
4025 assigning :attr:`model_title_plural`.
4026 """
4027 if hasattr(cls, "model_title_plural"):
4028 return cls.model_title_plural
4030 if model_class := cls.get_model_class():
4031 if hasattr(model_class, "__wutta_hint__"):
4032 if model_title_plural := model_class.__wutta_hint__.get(
4033 "model_title_plural"
4034 ):
4035 return model_title_plural
4037 model_title = cls.get_model_title()
4038 return f"{model_title}s"
4040 @classmethod
4041 def get_model_key(cls):
4042 """
4043 Returns the "model key" for the master view.
4045 This should return a tuple containing one or more "field
4046 names" corresponding to the primary key for data records.
4048 In the most simple/common scenario, where the master view
4049 represents a Wutta-based SQLAlchemy model, the return value
4050 for this method is: ``('uuid',)``
4052 Any class mapped via SQLAlchemy should be supported
4053 automatically, the keys are determined from class inspection.
4055 But there is no "sane" default for other scenarios, in which
4056 case subclass should define :attr:`model_key`. If the model
4057 key cannot be determined, raises ``AttributeError``.
4059 :returns: Tuple of field names comprising the model key.
4060 """
4061 if hasattr(cls, "model_key"):
4062 keys = cls.model_key
4063 if isinstance(keys, str):
4064 keys = [keys]
4065 return tuple(keys)
4067 model_class = cls.get_model_class()
4068 if model_class:
4069 # nb. we want the primary key but must avoid column names
4070 # in case mapped class uses different prop keys
4071 inspector = sa.inspect(model_class)
4072 keys = [col.name for col in inspector.primary_key]
4073 return tuple(
4074 prop.key
4075 for prop in inspector.column_attrs
4076 if all(col.name in keys for col in prop.columns)
4077 )
4079 raise AttributeError(f"you must define model_key for view class: {cls}")
4081 @classmethod
4082 def get_route_prefix(cls):
4083 """
4084 Returns the "route prefix" for the master view. This prefix
4085 is used for all named routes defined by the view class.
4087 For instance if route prefix is ``'widgets'`` then a view
4088 might have these routes:
4090 * ``'widgets'``
4091 * ``'widgets.create'``
4092 * ``'widgets.edit'``
4093 * ``'widgets.delete'``
4095 The default logic will call
4096 :meth:`get_model_name_normalized()` and simply add an ``'s'``
4097 to the end, making it plural. A subclass may override by
4098 assigning :attr:`route_prefix`.
4099 """
4100 if hasattr(cls, "route_prefix"):
4101 return cls.route_prefix
4103 model_name = cls.get_model_name_normalized()
4104 return f"{model_name}s"
4106 @classmethod
4107 def get_permission_prefix(cls):
4108 """
4109 Returns the "permission prefix" for the master view. This
4110 prefix is used for all permissions defined by the view class.
4112 For instance if permission prefix is ``'widgets'`` then a view
4113 might have these permissions:
4115 * ``'widgets.list'``
4116 * ``'widgets.create'``
4117 * ``'widgets.edit'``
4118 * ``'widgets.delete'``
4120 The default logic will call :meth:`get_route_prefix()` and use
4121 that value as-is. A subclass may override by assigning
4122 :attr:`permission_prefix`.
4123 """
4124 if hasattr(cls, "permission_prefix"):
4125 return cls.permission_prefix
4127 return cls.get_route_prefix()
4129 @classmethod
4130 def get_url_prefix(cls):
4131 """
4132 Returns the "URL prefix" for the master view. This prefix is
4133 used for all URLs defined by the view class.
4135 Using the same example as in :meth:`get_route_prefix()`, the
4136 URL prefix would be ``'/widgets'`` and the view would have
4137 defined routes for these URLs:
4139 * ``/widgets/``
4140 * ``/widgets/new``
4141 * ``/widgets/XXX/edit``
4142 * ``/widgets/XXX/delete``
4144 The default logic will call :meth:`get_route_prefix()` and
4145 simply add a ``'/'`` to the beginning. A subclass may
4146 override by assigning :attr:`url_prefix`.
4147 """
4148 if hasattr(cls, "url_prefix"):
4149 return cls.url_prefix
4151 route_prefix = cls.get_route_prefix()
4152 return f"/{route_prefix}"
4154 @classmethod
4155 def get_instance_url_prefix(cls):
4156 """
4157 Generate the URL prefix specific to an instance for this model
4158 view. This will include model key param placeholders; it
4159 winds up looking like:
4161 * ``/widgets/{uuid}``
4162 * ``/resources/{foo}|{bar}|{baz}``
4164 The former being the most simple/common, and the latter
4165 showing what a "composite" model key looks like, with pipe
4166 symbols separating the key parts.
4167 """
4168 prefix = cls.get_url_prefix() + "/"
4169 for i, key in enumerate(cls.get_model_key()):
4170 if i:
4171 prefix += "|"
4172 prefix += f"{{{key}}}"
4173 return prefix
4175 @classmethod
4176 def get_template_prefix(cls):
4177 """
4178 Returns the "template prefix" for the master view. This
4179 prefix is used to guess which template path to render for a
4180 given view.
4182 Using the same example as in :meth:`get_url_prefix()`, the
4183 template prefix would also be ``'/widgets'`` and the templates
4184 assumed for those routes would be:
4186 * ``/widgets/index.mako``
4187 * ``/widgets/create.mako``
4188 * ``/widgets/edit.mako``
4189 * ``/widgets/delete.mako``
4191 The default logic will call :meth:`get_url_prefix()` and
4192 return that value as-is. A subclass may override by assigning
4193 :attr:`template_prefix`.
4194 """
4195 if hasattr(cls, "template_prefix"):
4196 return cls.template_prefix
4198 return cls.get_url_prefix()
4200 @classmethod
4201 def get_grid_key(cls):
4202 """
4203 Returns the (presumably) unique key to be used for the primary
4204 grid in the :meth:`index()` view. This key may also be used
4205 as the basis (key prefix) for secondary grids.
4207 This is called from :meth:`make_model_grid()`; in the
4208 resulting :class:`~wuttaweb.grids.base.Grid` instance, this
4209 becomes :attr:`~wuttaweb.grids.base.Grid.key`.
4211 The default logic for this method will call
4212 :meth:`get_route_prefix()` and return that value as-is. A
4213 subclass may override by assigning :attr:`grid_key`.
4214 """
4215 if hasattr(cls, "grid_key"):
4216 return cls.grid_key
4218 return cls.get_route_prefix()
4220 @classmethod
4221 def get_config_title(cls):
4222 """
4223 Returns the "config title" for the view/model.
4225 The config title is used for page title in the
4226 :meth:`configure()` view, as well as links to it. It is
4227 usually plural, e.g. ``"Wutta Widgets"`` in which case that
4228 winds up being displayed in the web app as: **Configure Wutta
4229 Widgets**
4231 The default logic will call :meth:`get_model_title_plural()`
4232 and return that as-is. A subclass may override by assigning
4233 :attr:`config_title`.
4234 """
4235 if hasattr(cls, "config_title"):
4236 return cls.config_title
4238 return cls.get_model_title_plural()
4240 @classmethod
4241 def get_row_model_class(cls):
4242 """
4243 Returns the "child row" model class for the view. Only
4244 relevant if :attr:`has_rows` is true.
4246 Default logic returns the :attr:`row_model_class` reference.
4248 :returns: Mapped class, or ``None``
4249 """
4250 return cls.row_model_class
4252 @classmethod
4253 def get_row_model_name(cls):
4254 """
4255 Returns the row model name for the view.
4257 A model name should generally be in the format of a Python
4258 class name, e.g. ``'BatchRow'``. (Note this is *singular*,
4259 not plural.)
4261 The default logic will call :meth:`get_row_model_class()` and
4262 return that class name as-is. Subclass may override by
4263 assigning :attr:`row_model_name`.
4264 """
4265 if hasattr(cls, "row_model_name"):
4266 return cls.row_model_name
4268 return cls.get_row_model_class().__name__
4270 @classmethod
4271 def get_row_model_title(cls):
4272 """
4273 Returns the "humanized" (singular) title for the row model.
4275 The model title will be displayed to the user, so should have
4276 proper grammar and capitalization, e.g. ``"Batch Row"``.
4277 (Note this is *singular*, not plural.)
4279 The default logic will call :meth:`get_row_model_name()` and
4280 use the result as-is. Subclass may override by assigning
4281 :attr:`row_model_title`.
4283 See also :meth:`get_row_model_title_plural()`.
4284 """
4285 if hasattr(cls, "row_model_title"):
4286 return cls.row_model_title
4288 if model_class := cls.get_row_model_class():
4289 if hasattr(model_class, "__wutta_hint__"):
4290 if model_title := model_class.__wutta_hint__.get("model_title"):
4291 return model_title
4293 return cls.get_row_model_name()
4295 @classmethod
4296 def get_row_model_title_plural(cls):
4297 """
4298 Returns the "humanized" (plural) title for the row model.
4300 The model title will be displayed to the user, so should have
4301 proper grammar and capitalization, e.g. ``"Batch Rows"``.
4302 (Note this is *plural*, not singular.)
4304 The default logic will call :meth:`get_row_model_title()` and
4305 simply add a ``'s'`` to the end. Subclass may override by
4306 assigning :attr:`row_model_title_plural`.
4307 """
4308 if hasattr(cls, "row_model_title_plural"):
4309 return cls.row_model_title_plural
4311 if model_class := cls.get_row_model_class():
4312 if hasattr(model_class, "__wutta_hint__"):
4313 if model_title_plural := model_class.__wutta_hint__.get(
4314 "model_title_plural"
4315 ):
4316 return model_title_plural
4318 row_model_title = cls.get_row_model_title()
4319 return f"{row_model_title}s"
4321 ##############################
4322 # configuration
4323 ##############################
4325 @classmethod
4326 def defaults(cls, config):
4327 """
4328 Provide default Pyramid configuration for a master view.
4330 This is generally called from within the module's
4331 ``includeme()`` function, e.g.::
4333 from wuttaweb.views import MasterView
4335 class WidgetView(MasterView):
4336 model_name = 'Widget'
4338 def includeme(config):
4339 WidgetView.defaults(config)
4341 :param config: Reference to the app's
4342 :class:`pyramid:pyramid.config.Configurator` instance.
4343 """
4344 cls._defaults(config)
4346 @classmethod
4347 def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches
4348 wutta_config = config.registry.settings.get("wutta_config")
4349 app = wutta_config.get_app()
4351 route_prefix = cls.get_route_prefix()
4352 permission_prefix = cls.get_permission_prefix()
4353 url_prefix = cls.get_url_prefix()
4354 model_title = cls.get_model_title()
4355 model_title_plural = cls.get_model_title_plural()
4357 # add to master view registry
4358 config.add_wutta_master_view(cls)
4360 # permission group
4361 config.add_wutta_permission_group(
4362 permission_prefix, model_title_plural, overwrite=False
4363 )
4365 # index
4366 if cls.listable:
4367 config.add_route(route_prefix, f"{url_prefix}/")
4368 config.add_view(
4369 cls,
4370 attr="index",
4371 route_name=route_prefix,
4372 permission=f"{permission_prefix}.list",
4373 )
4374 config.add_wutta_permission(
4375 permission_prefix,
4376 f"{permission_prefix}.list",
4377 f"Browse / search {model_title_plural}",
4378 )
4380 # grid totals
4381 if cls.has_grid_totals:
4382 config.add_route(
4383 f"{route_prefix}.fetch_grid_totals",
4384 f"{url_prefix}/fetch-grid-totals",
4385 request_method="GET",
4386 )
4387 config.add_view(
4388 cls,
4389 attr="fetch_grid_totals",
4390 route_name=f"{route_prefix}.fetch_grid_totals",
4391 permission=f"{permission_prefix}.list",
4392 renderer="json",
4393 )
4395 # create
4396 if cls.creatable:
4397 config.add_route(f"{route_prefix}.create", f"{url_prefix}/new")
4398 config.add_view(
4399 cls,
4400 attr="create",
4401 route_name=f"{route_prefix}.create",
4402 permission=f"{permission_prefix}.create",
4403 )
4404 config.add_wutta_permission(
4405 permission_prefix,
4406 f"{permission_prefix}.create",
4407 f"Create new {model_title}",
4408 )
4410 # edit
4411 if cls.editable:
4412 instance_url_prefix = cls.get_instance_url_prefix()
4413 config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit")
4414 config.add_view(
4415 cls,
4416 attr="edit",
4417 route_name=f"{route_prefix}.edit",
4418 permission=f"{permission_prefix}.edit",
4419 )
4420 config.add_wutta_permission(
4421 permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}"
4422 )
4424 # delete
4425 if cls.deletable:
4426 instance_url_prefix = cls.get_instance_url_prefix()
4427 config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete")
4428 config.add_view(
4429 cls,
4430 attr="delete",
4431 route_name=f"{route_prefix}.delete",
4432 permission=f"{permission_prefix}.delete",
4433 )
4434 config.add_wutta_permission(
4435 permission_prefix,
4436 f"{permission_prefix}.delete",
4437 f"Delete {model_title}",
4438 )
4440 # bulk delete
4441 if cls.deletable_bulk:
4442 config.add_route(
4443 f"{route_prefix}.delete_bulk",
4444 f"{url_prefix}/delete-bulk",
4445 request_method="POST",
4446 )
4447 config.add_view(
4448 cls,
4449 attr="delete_bulk",
4450 route_name=f"{route_prefix}.delete_bulk",
4451 permission=f"{permission_prefix}.delete_bulk",
4452 )
4453 config.add_wutta_permission(
4454 permission_prefix,
4455 f"{permission_prefix}.delete_bulk",
4456 f"Delete {model_title_plural} in bulk",
4457 )
4459 # merge
4460 if cls.mergeable:
4461 config.add_wutta_permission(
4462 permission_prefix,
4463 f"{permission_prefix}.merge",
4464 f"Merge 2 {model_title_plural}",
4465 )
4466 config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge")
4467 config.add_view(
4468 cls,
4469 attr="merge",
4470 route_name=f"{route_prefix}.merge",
4471 permission=f"{permission_prefix}.merge",
4472 )
4474 # autocomplete
4475 if cls.has_autocomplete:
4476 config.add_route(
4477 f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete"
4478 )
4479 config.add_view(
4480 cls,
4481 attr="autocomplete",
4482 route_name=f"{route_prefix}.autocomplete",
4483 renderer="json",
4484 permission=f"{route_prefix}.list",
4485 )
4487 # download
4488 if cls.downloadable:
4489 instance_url_prefix = cls.get_instance_url_prefix()
4490 config.add_route(
4491 f"{route_prefix}.download", f"{instance_url_prefix}/download"
4492 )
4493 config.add_view(
4494 cls,
4495 attr="download",
4496 route_name=f"{route_prefix}.download",
4497 permission=f"{permission_prefix}.download",
4498 )
4499 config.add_wutta_permission(
4500 permission_prefix,
4501 f"{permission_prefix}.download",
4502 f"Download file(s) for {model_title}",
4503 )
4505 # execute
4506 if cls.executable:
4507 instance_url_prefix = cls.get_instance_url_prefix()
4508 config.add_route(
4509 f"{route_prefix}.execute",
4510 f"{instance_url_prefix}/execute",
4511 request_method="POST",
4512 )
4513 config.add_view(
4514 cls,
4515 attr="execute",
4516 route_name=f"{route_prefix}.execute",
4517 permission=f"{permission_prefix}.execute",
4518 )
4519 config.add_wutta_permission(
4520 permission_prefix,
4521 f"{permission_prefix}.execute",
4522 f"Execute {model_title}",
4523 )
4525 # configure
4526 if cls.configurable:
4527 config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure")
4528 config.add_view(
4529 cls,
4530 attr="configure",
4531 route_name=f"{route_prefix}.configure",
4532 permission=f"{permission_prefix}.configure",
4533 )
4534 config.add_wutta_permission(
4535 permission_prefix,
4536 f"{permission_prefix}.configure",
4537 f"Configure {model_title_plural}",
4538 )
4540 # view
4541 # nb. always register this one last, so it does not take
4542 # priority over model-wide action routes, e.g. delete_bulk
4543 if cls.viewable:
4544 instance_url_prefix = cls.get_instance_url_prefix()
4545 config.add_route(f"{route_prefix}.view", instance_url_prefix)
4546 config.add_view(
4547 cls,
4548 attr="view",
4549 route_name=f"{route_prefix}.view",
4550 permission=f"{permission_prefix}.view",
4551 )
4552 config.add_wutta_permission(
4553 permission_prefix, f"{permission_prefix}.view", f"View {model_title}"
4554 )
4556 # version history
4557 if cls.is_versioned() and app.continuum_is_enabled():
4558 instance_url_prefix = cls.get_instance_url_prefix()
4559 config.add_wutta_permission(
4560 permission_prefix,
4561 f"{permission_prefix}.versions",
4562 f"View version history for {model_title}",
4563 )
4564 config.add_route(
4565 f"{route_prefix}.versions", f"{instance_url_prefix}/versions/"
4566 )
4567 config.add_view(
4568 cls,
4569 attr="view_versions",
4570 route_name=f"{route_prefix}.versions",
4571 permission=f"{permission_prefix}.versions",
4572 )
4573 config.add_route(
4574 f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}"
4575 )
4576 config.add_view(
4577 cls,
4578 attr="view_version",
4579 route_name=f"{route_prefix}.version",
4580 permission=f"{permission_prefix}.versions",
4581 )
4583 ##############################
4584 # row-specific routes
4585 ##############################
4587 # create row
4588 if cls.has_rows and cls.rows_creatable:
4589 config.add_wutta_permission(
4590 permission_prefix,
4591 f"{permission_prefix}.create_row",
4592 f'Create new "rows" for {model_title}',
4593 )
4594 config.add_route(
4595 f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row"
4596 )
4597 config.add_view(
4598 cls,
4599 attr="create_row",
4600 route_name=f"{route_prefix}.create_row",
4601 permission=f"{permission_prefix}.create_row",
4602 )