Source code for wuttaweb.views.master

# -*- coding: utf-8; -*-
################################################################################
#
#  wuttaweb -- Web App for Wutta Framework
#  Copyright © 2024-2026 Lance Edgar
#
#  This file is part of Wutta Framework.
#
#  Wutta Framework is free software: you can redistribute it and/or modify it
#  under the terms of the GNU General Public License as published by the Free
#  Software Foundation, either version 3 of the License, or (at your option) any
#  later version.
#
#  Wutta Framework is distributed in the hope that it will be useful, but
#  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
#  more details.
#
#  You should have received a copy of the GNU General Public License along with
#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Base Logic for Master Views
"""
# pylint: disable=too-many-lines

import logging
import os
import threading
import warnings
from uuid import UUID

import sqlalchemy as sa
from sqlalchemy import orm

from pyramid.renderers import render_to_response
from webhelpers2.html import HTML, tags

from wuttjamaican.util import get_class_hierarchy
from wuttaweb.views.base import View
from wuttaweb.util import get_form_data, render_csrf_token
from wuttaweb.db import Session
from wuttaweb.progress import SessionProgress
from wuttaweb.diffs import MergeDiff, VersionDiff


log = logging.getLogger(__name__)


[docs] class MasterView(View): # pylint: disable=too-many-public-methods """ Base class for "master" views. Master views typically map to a table in a DB, though not always. They essentially are a set of CRUD views for a certain type of data record. Many attributes may be overridden in subclass. For instance to define :attr:`model_class`:: from wuttaweb.views import MasterView from wuttjamaican.db.model import Person class MyPersonView(MasterView): model_class = Person def includeme(config): MyPersonView.defaults(config) .. note:: Many of these attributes will only exist if they have been explicitly defined in a subclass. There are corresponding ``get_xxx()`` methods which should be used instead of accessing these attributes directly. .. attribute:: model_class Optional reference to a :term:`data model` class. While not strictly required, most views will set this to a SQLAlchemy mapped class, e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. The base logic should not access this directly but instead call :meth:`get_model_class()`. .. attribute:: model_name Optional override for the view's data model name, e.g. ``'WuttaWidget'``. Code should not access this directly but instead call :meth:`get_model_name()`. .. attribute:: model_name_normalized Optional override for the view's "normalized" data model name, e.g. ``'wutta_widget'``. Code should not access this directly but instead call :meth:`get_model_name_normalized()`. .. attribute:: model_title Optional override for the view's "humanized" (singular) model title, e.g. ``"Wutta Widget"``. Code should not access this directly but instead call :meth:`get_model_title()`. .. attribute:: model_title_plural Optional override for the view's "humanized" (plural) model title, e.g. ``"Wutta Widgets"``. Code should not access this directly but instead call :meth:`get_model_title_plural()`. .. attribute:: model_key Optional override for the view's "model key" - e.g. ``'id'`` (string for simple case) or composite key such as ``('id_field', 'name_field')``. If :attr:`model_class` is set to a SQLAlchemy mapped class, the model key can be determined automatically. Code should not access this directly but instead call :meth:`get_model_key()`. .. attribute:: grid_key Optional override for the view's grid key, e.g. ``'widgets'``. Code should not access this directly but instead call :meth:`get_grid_key()`. .. attribute:: config_title Optional override for the view's "config" title, e.g. ``"Wutta Widgets"`` (to be displayed as **Configure Wutta Widgets**). Code should not access this directly but instead call :meth:`get_config_title()`. .. attribute:: route_prefix Optional override for the view's route prefix, e.g. ``'wutta_widgets'``. Code should not access this directly but instead call :meth:`get_route_prefix()`. .. attribute:: permission_prefix Optional override for the view's permission prefix, e.g. ``'wutta_widgets'``. Code should not access this directly but instead call :meth:`get_permission_prefix()`. .. attribute:: url_prefix Optional override for the view's URL prefix, e.g. ``'/widgets'``. Code should not access this directly but instead call :meth:`get_url_prefix()`. .. attribute:: template_prefix Optional override for the view's template prefix, e.g. ``'/widgets'``. Code should not access this directly but instead call :meth:`get_template_prefix()`. .. attribute:: listable Boolean indicating whether the view model supports "listing" - i.e. it should have an :meth:`index()` view. Default value is ``True``. .. attribute:: has_grid Boolean indicating whether the :meth:`index()` view should include a grid. Default value is ``True``. .. attribute:: grid_columns List of columns for the :meth:`index()` view grid. This is optional; see also :meth:`get_grid_columns()`. .. attribute:: has_grid_totals Boolean indicating whether the main grid supports a "Show Totals" feature; this is false by default. See also :meth:`fetch_grid_totals()`. .. attribute:: checkable Boolean indicating whether the grid should expose per-row checkboxes. This is passed along to set :attr:`~wuttaweb.grids.base.Grid.checkable` on the grid. .. method:: grid_row_class(obj, data, i) This method is *not* defined on the ``MasterView`` base class; however if a subclass defines it then it will be automatically used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for the main :meth:`index()` grid. For more info see :meth:`~wuttaweb.grids.base.Grid.get_row_class()`. .. attribute:: filterable Boolean indicating whether the grid for the :meth:`index()` view should allow filtering of data. Default is ``True``. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.filterable` flag. .. attribute:: filter_defaults Optional dict of default filter state. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.filter_defaults`. Only relevant if :attr:`filterable` is true. .. attribute:: sortable Boolean indicating whether the grid for the :meth:`index()` view should allow sorting of data. Default is ``True``. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.sortable` flag. See also :attr:`sort_on_backend` and :attr:`sort_defaults`. .. attribute:: sort_on_backend Boolean indicating whether the grid data for the :meth:`index()` view should be sorted on the backend. Default is ``True``. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag. Only relevant if :attr:`sortable` is true. .. attribute:: sort_defaults Optional list of default sorting info. Applicable for both frontend and backend sorting. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.sort_defaults`. Only relevant if :attr:`sortable` is true. .. attribute:: paginated Boolean indicating whether the grid data for the :meth:`index()` view should be paginated. Default is ``True``. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.paginated` flag. .. attribute:: paginate_on_backend Boolean indicating whether the grid data for the :meth:`index()` view should be paginated on the backend. Default is ``True``. This is used by :meth:`make_model_grid()` to set the grid's :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag. .. attribute:: creatable Boolean indicating whether the view model supports "creating" - i.e. it should have a :meth:`create()` view. Default value is ``True``. .. attribute:: viewable Boolean indicating whether the view model supports "viewing" - i.e. it should have a :meth:`view()` view. Default value is ``True``. .. attribute:: editable Boolean indicating whether the view model supports "editing" - i.e. it should have an :meth:`edit()` view. Default value is ``True``. See also :meth:`is_editable()`. .. attribute:: deletable Boolean indicating whether the view model supports "deleting" - i.e. it should have a :meth:`delete()` view. Default value is ``True``. See also :meth:`is_deletable()`. .. attribute:: deletable_bulk Boolean indicating whether the view model supports "bulk deleting" - i.e. it should have a :meth:`delete_bulk()` view. Default value is ``False``. See also :attr:`deletable_bulk_quick`. .. attribute:: deletable_bulk_quick Boolean indicating whether the view model supports "quick" bulk deleting, i.e. the operation is reliably quick enough that it should happen *synchronously* with no progress indicator. Default is ``False`` in which case a progress indicator is shown while the bulk deletion is performed. Only relevant if :attr:`deletable_bulk` is true. .. attribute:: form_fields List of fields for the model form. This is optional; see also :meth:`get_form_fields()`. .. attribute:: has_autocomplete Boolean indicating whether the view model supports "autocomplete" - i.e. it should have an :meth:`autocomplete()` view. Default is ``False``. .. attribute:: downloadable Boolean indicating whether the view model supports "downloading" - i.e. it should have a :meth:`download()` view. Default is ``False``. .. attribute:: executable Boolean indicating whether the view model supports "executing" - i.e. it should have an :meth:`execute()` view. Default is ``False``. .. attribute:: configurable Boolean indicating whether the master view supports "configuring" - i.e. it should have a :meth:`configure()` view. Default value is ``False``. .. attribute:: version_grid_columns List of columns for the :meth:`view_versions()` view grid. This is optional; see also :meth:`get_version_grid_columns()`. .. attribute:: mergeable Boolean indicating whether the view model supports "merging two records" - i.e. it should have a :meth:`merge()` view. Default value is ``False``. .. attribute:: merge_additive_fields Optional list of fields for which values are "additive" in nature when merging two records. Only relevant if :attr:`mergeable` is true. See also :meth:`merge_get_additive_fields()`. .. attribute:: merge_coalesce_fields Optional list of fields for which values should be "coalesced" when merging two records. Only relevant if :attr:`mergeable` is true. See also :meth:`merge_get_coalesce_fields()`. .. attribute:: merge_simple_fields Optional list of "simple" fields when merging two records. Only relevant if :attr:`mergeable` is true. See also :meth:`merge_get_simple_fields()`. **ROW FEATURES** .. attribute:: has_rows Whether the model has "child rows" which should also be displayed when viewing model records. For instance when viewing a :term:`batch` you want to see both the batch header as well as its row data. This the "master switch" for all row features; if this is turned on then many other things kick in. See also :attr:`row_model_class`. .. attribute:: row_model_class Reference to the :term:`data model` class for the child rows. Subclass should define this if :attr:`has_rows` is true. View logic should not access this directly but instead call :meth:`get_row_model_class()`. .. attribute:: row_model_name Optional override for the view's row model name, e.g. ``'WuttaWidget'``. Code should not access this directly but instead call :meth:`get_row_model_name()`. .. attribute:: row_model_title Optional override for the view's "humanized" (singular) row model title, e.g. ``"Wutta Widget"``. Code should not access this directly but instead call :meth:`get_row_model_title()`. .. attribute:: row_model_title_plural Optional override for the view's "humanized" (plural) row model title, e.g. ``"Wutta Widgets"``. Code should not access this directly but instead call :meth:`get_row_model_title_plural()`. .. attribute:: rows_title Display title for the rows grid. The base logic should not access this directly but instead call :meth:`get_rows_title()`. .. attribute:: row_grid_columns List of columns for the row grid. This is optional; see also :meth:`get_row_grid_columns()`. .. attribute:: rows_viewable Boolean indicating whether the row model supports "viewing" - i.e. the row grid should have a "View" action. Default value is ``False``. (For now) If you enable this, you must also override :meth:`get_row_action_url_view()`. .. note:: This eventually will cause there to be a ``row_view`` route to be configured as well. .. attribute:: row_form_fields List of fields for the row model form. This is optional; see also :meth:`get_row_form_fields()`. .. attribute:: rows_creatable Boolean indicating whether the row model supports "creating" - i.e. a route should be defined for :meth:`create_row()`. Default value is ``False``. """ ############################## # attributes ############################## model_class = None # features listable = True has_grid = True has_grid_totals = False checkable = False filterable = True filter_defaults = None sortable = True sort_on_backend = True sort_defaults = None paginated = True paginate_on_backend = True creatable = True viewable = True editable = True deletable = True deletable_bulk = False deletable_bulk_quick = False has_autocomplete = False downloadable = False executable = False execute_progress_template = None configurable = False # merging mergeable = False merge_additive_fields = None merge_coalesce_fields = None merge_simple_fields = None # row features has_rows = False row_model_class = None rows_filterable = True rows_filter_defaults = None rows_sortable = True rows_sort_on_backend = True rows_sort_defaults = None rows_paginated = True rows_paginate_on_backend = True rows_viewable = False rows_creatable = False # current action listing = False creating = False viewing = False editing = False deleting = False executing = False configuring = False # default DB session Session = Session ############################## # index methods ##############################
[docs] def index(self): """ View to "list" (filter/browse) the model data. This is the "default" view for the model and is what user sees when visiting the "root" path under the :attr:`url_prefix`, e.g. ``/widgets/``. By default, this view is included only if :attr:`listable` is true. The default view logic will show a "grid" (table) with the model data (unless :attr:`has_grid` is false). See also related methods, which are called by this one: * :meth:`make_model_grid()` """ self.listing = True context = { "index_url": None, # nb. avoid title link since this *is* the index } if self.has_grid: grid = self.make_model_grid() # handle "full" vs. "partial" differently if self.request.GET.get("partial"): # so-called 'partial' requests get just data, no html context = grid.get_vue_context() if grid.paginated and grid.paginate_on_backend: context["pager_stats"] = grid.get_vue_pager_stats() return self.json_response(context) # full, not partial # nb. when user asks to reset view, it is via the query # string. if so we then redirect to discard that. if self.request.GET.get("reset-view"): # nb. we want to preserve url hash if applicable kw = {"_query": None, "_anchor": self.request.GET.get("hash")} return self.redirect(self.request.current_route_url(**kw)) context["grid"] = grid return self.render_to_response("index", context)
[docs] def fetch_grid_totals(self): """ Should return the "totals info" for the main grid, if applicable. Only relevant when :attr:`has_grid_totals` is true. This method is called "on demand" from the client side; totals are not calculated / shown by default when a grid is first displayed on the page. Subclass should override this method to calculate and return the customized info. Default logic within the template is expecting a ``totals_html`` key within the dict; this will be rendered as-is on the page. For instance:: def fetch_grid_totals(self): from webhelpers2.html import HTML # get current data set from grid # nb. this will be filtered and sorted but *not* # paginated; we want to include all results. grid = self.make_model_grid(paginated=False) rows = grid.get_visible_data() # calculate total foo_total = sum([row.foo_amount for row in rows]) # render as <span> tag html = HTML.tag("span", c=f"Foo Total: {foo_total:0.2f}") return {"totals_html": html} :returns: Dict of totals info. """ return {"totals_html": "TODO: totals go here"}
############################## # create methods ##############################
[docs] def create(self): """ View to "create" a new model record. This usually corresponds to URL like ``/widgets/new`` By default, this route is included only if :attr:`creatable` is true. The default logic calls :meth:`make_create_form()` and shows that to the user. When they submit valid data, it calls :meth:`save_create_form()` and then :meth:`redirect_after_create()`. """ self.creating = True form = self.make_create_form() if form.validate(): session = self.Session() try: result = self.save_create_form(form) # nb. must always flush to ensure primary key is set session.flush() except Exception as err: # pylint: disable=broad-exception-caught log.warning("failed to save 'create' form", exc_info=True) self.request.session.flash(f"Create failed: {err}", "error") else: return self.redirect_after_create(result) context = {"form": form} return self.render_to_response("create", context)
[docs] def make_create_form(self): """ Make the "create" model form. This is called by :meth:`create()`. Default logic calls :meth:`make_model_form()`. :returns: :class:`~wuttaweb.forms.base.Form` instance """ return self.make_model_form(cancel_url_fallback=self.get_index_url())
[docs] def save_create_form(self, form): """ Save the "create" form. This is called by :meth:`create()`. Default logic calls :meth:`objectify()` and then :meth:`persist()`. Subclass is expected to override for non-standard use cases. As for return value, by default it will be whatever came back from the ``objectify()`` call. In practice a subclass can return whatever it likes. The value is only used as input to :meth:`redirect_after_create()`. :returns: Usually the model instance, but can be "anything" """ if hasattr(self, "create_save_form"): # pragma: no cover warnings.warn( "MasterView.create_save_form() method name is deprecated; " f"please refactor to save_create_form() instead for {self.__class__.__name__}", DeprecationWarning, ) return self.create_save_form(form) obj = self.objectify(form) self.persist(obj) return obj
[docs] def redirect_after_create(self, result): """ Must return a redirect, following successful save of the "create" form. This is called by :meth:`create()`. By default this redirects to the "view" page for the new record. :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance """ return self.redirect(self.get_action_url("view", result))
############################## # view methods ##############################
[docs] def view(self): """ View to "view" a model record. This usually corresponds to URL like ``/widgets/XXX`` By default, this route is included only if :attr:`viewable` is true. The default logic here is as follows: First, if :attr:`has_rows` is true then :meth:`make_row_model_grid()` is called. If ``has_rows`` is true *and* the request has certain special params relating to the grid, control may exit early. Mainly this happens when a "partial" page is requested, which means we just return grid data and nothing else. (Used for backend sorting and pagination etc.) Otherwise :meth:`make_view_form()` is called, and the template is rendered. """ self.viewing = True obj = self.get_instance() context = {"instance": obj} if self.has_rows: # always make the grid first. note that it already knows # to "reset" its params when that is requested. grid = self.make_row_model_grid(obj) # but if user did request a "reset" then we want to # redirect so the query string gets cleared out if self.request.GET.get("reset-view"): # nb. we want to preserve url hash if applicable kw = {"_query": None, "_anchor": self.request.GET.get("hash")} return self.redirect(self.request.current_route_url(**kw)) # so-called 'partial' requests get just the grid data if self.request.params.get("partial"): context = grid.get_vue_context() if grid.paginated and grid.paginate_on_backend: context["pager_stats"] = grid.get_vue_pager_stats() return self.json_response(context) context["rows_grid"] = grid context["form"] = self.make_view_form(obj) context["xref_buttons"] = self.get_xref_buttons(obj) return self.render_to_response("view", context)
[docs] def make_view_form(self, obj, readonly=True): """ Make the "view" model form. This is called by :meth:`view()`. Default logic calls :meth:`make_model_form()`. :returns: :class:`~wuttaweb.forms.base.Form` instance """ return self.make_model_form(obj, readonly=readonly)
############################## # edit methods ##############################
[docs] def edit(self): """ View to "edit" a model record. This usually corresponds to URL like ``/widgets/XXX/edit`` By default, this route is included only if :attr:`editable` is true. The default logic calls :meth:`make_edit_form()` and shows that to the user. When they submit valid data, it calls :meth:`save_edit_form()` and then :meth:`redirect_after_edit()`. """ self.editing = True instance = self.get_instance() form = self.make_edit_form(instance) if form.validate(): try: result = self.save_edit_form(form) except Exception as err: # pylint: disable=broad-exception-caught log.warning("failed to save 'edit' form", exc_info=True) self.request.session.flash(f"Edit failed: {err}", "error") else: return self.redirect_after_edit(result) context = { "instance": instance, "form": form, } return self.render_to_response("edit", context)
[docs] def make_edit_form(self, obj): """ Make the "edit" model form. This is called by :meth:`edit()`. Default logic calls :meth:`make_model_form()`. :returns: :class:`~wuttaweb.forms.base.Form` instance """ return self.make_model_form( obj, cancel_url_fallback=self.get_action_url("view", obj) )
[docs] def save_edit_form(self, form): """ Save the "edit" form. This is called by :meth:`edit()`. Default logic calls :meth:`objectify()` and then :meth:`persist()`. Subclass is expected to override for non-standard use cases. As for return value, by default it will be whatever came back from the ``objectify()`` call. In practice a subclass can return whatever it likes. The value is only used as input to :meth:`redirect_after_edit()`. :returns: Usually the model instance, but can be "anything" """ if hasattr(self, "edit_save_form"): # pragma: no cover warnings.warn( "MasterView.edit_save_form() method name is deprecated; " f"please refactor to save_edit_form() instead for {self.__class__.__name__}", DeprecationWarning, ) return self.edit_save_form(form) obj = self.objectify(form) self.persist(obj) return obj
[docs] def redirect_after_edit(self, result): """ Must return a redirect, following successful save of the "edit" form. This is called by :meth:`edit()`. By default this redirects to the "view" page for the record. :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance """ return self.redirect(self.get_action_url("view", result))
############################## # delete methods ##############################
[docs] def delete(self): """ View to "delete" a model record. This usually corresponds to URL like ``/widgets/XXX/delete`` By default, this route is included only if :attr:`deletable` is true. The default logic calls :meth:`make_delete_form()` and shows that to the user. When they submit, it calls :meth:`save_delete_form()` and then :meth:`redirect_after_delete()`. """ self.deleting = True instance = self.get_instance() if not self.is_deletable(instance): return self.redirect(self.get_action_url("view", instance)) form = self.make_delete_form(instance) # nb. validate() often returns empty dict here if form.validate() is not False: try: result = self.save_delete_form( # pylint: disable=assignment-from-none form ) except Exception as err: # pylint: disable=broad-exception-caught log.warning("failed to save 'delete' form", exc_info=True) self.request.session.flash(f"Delete failed: {err}", "error") else: return self.redirect_after_delete(result) context = { "instance": instance, "form": form, } return self.render_to_response("delete", context)
[docs] def make_delete_form(self, obj): """ Make the "delete" model form. This is called by :meth:`delete()`. Default logic calls :meth:`make_model_form()` but with a twist: The form proper is *not* readonly; this ensures the form has a submit button etc. But then all fields in the form are explicitly marked readonly. :returns: :class:`~wuttaweb.forms.base.Form` instance """ # nb. this form proper is not readonly.. form = self.make_model_form( obj, cancel_url_fallback=self.get_action_url("view", obj), button_label_submit="DELETE Forever", button_icon_submit="trash", button_type_submit="is-danger", ) # ..but *all* fields are readonly form.readonly_fields = set(form.fields) return form
[docs] def save_delete_form(self, form): """ Save the "delete" form. This is called by :meth:`delete()`. Default logic calls :meth:`delete_instance()`. Normally subclass would override that for non-standard use cases, but it could also/instead override this method. As for return value, by default this returns ``None``. In practice a subclass can return whatever it likes. The value is only used as input to :meth:`redirect_after_delete()`. :returns: Usually ``None``, but can be "anything" """ if hasattr(self, "delete_save_form"): # pragma: no cover warnings.warn( "MasterView.delete_save_form() method name is deprecated; " f"please refactor to save_delete_form() instead for {self.__class__.__name__}", DeprecationWarning, ) self.delete_save_form(form) return obj = form.model_instance self.delete_instance(obj)
[docs] def redirect_after_delete(self, result): # pylint: disable=unused-argument """ Must return a redirect, following successful save of the "delete" form. This is called by :meth:`delete()`. By default this redirects back to the :meth:`index()` page. :returns: :class:`~pyramid.httpexceptions.HTTPFound` instance """ return self.redirect(self.get_index_url())
[docs] def delete_instance(self, obj): """ Delete the given model instance. As of yet there is no default logic for this method; it will raise ``NotImplementedError``. Subclass should override if needed. This method is called by :meth:`save_delete_form()`. """ session = self.app.get_session(obj) session.delete(obj)
[docs] def delete_bulk(self): """ View to delete all records in the current :meth:`index()` grid data set, i.e. those matching current query. This usually corresponds to a URL like ``/widgets/delete-bulk``. By default, this view is included only if :attr:`deletable_bulk` is true. This view requires POST method. When it is finished deleting, user is redirected back to :meth:`index()` view. Subclass normally should not override this method, but rather one of the related methods which are called (in)directly by this one: * :meth:`delete_bulk_action()` """ # get current data set from grid # nb. this must *not* be paginated, we need it all grid = self.make_model_grid(paginated=False) data = grid.get_visible_data() if self.deletable_bulk_quick: # delete it all and go back to listing self.delete_bulk_action(data) return self.redirect(self.get_index_url()) # start thread for delete; show progress page route_prefix = self.get_route_prefix() key = f"{route_prefix}.delete_bulk" progress = self.make_progress(key, success_url=self.get_index_url()) thread = threading.Thread( target=self.delete_bulk_thread, args=(data,), kwargs={"progress": progress}, ) thread.start() return self.render_progress(progress)
def delete_bulk_thread( # pylint: disable=empty-docstring self, query, progress=None ): """ """ session = self.app.make_session() records = query.with_session(session).all() def onerror(): log.warning( "failed to delete %s results for %s", len(records), self.get_model_title_plural(), exc_info=True, ) self.do_thread_body( self.delete_bulk_action, (records,), {"progress": progress}, onerror, session=session, progress=progress, )
[docs] def delete_bulk_action(self, data, progress=None): """ This method performs the actual bulk deletion, for the given data set. This is called via :meth:`delete_bulk()`. Default logic will call :meth:`is_deletable()` for every data record, and if that returns true then it calls :meth:`delete_instance()`. A progress indicator will be updated if one is provided. Subclass should override if needed. """ model_title_plural = self.get_model_title_plural() def delete(obj, i): # pylint: disable=unused-argument if self.is_deletable(obj): self.delete_instance(obj) self.app.progress_loop( delete, data, progress, message=f"Deleting {model_title_plural}" )
def delete_bulk_make_button(self): # pylint: disable=empty-docstring """ """ route_prefix = self.get_route_prefix() label = HTML.literal( '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}' ) button = self.make_button( label, variant="is-danger", icon_left="trash", **{"@click": "deleteResultsSubmit()", ":disabled": "deleteResultsDisabled"}, ) form = HTML.tag( "form", method="post", action=self.request.route_url(f"{route_prefix}.delete_bulk"), ref="deleteResultsForm", class_="control", c=[ render_csrf_token(self.request), button, ], ) return form ############################## # version history methods ##############################
[docs] @classmethod def is_versioned(cls): """ Returns boolean indicating whether the model class is configured for SQLAlchemy-Continuum versioning. The default logic will directly inspect the model class, as returned by :meth:`get_model_class()`. Or you can override by setting the ``model_is_versioned`` attribute:: class WidgetView(MasterView): model_class = Widget model_is_versioned = False See also :meth:`should_expose_versions()`. :returns: ``True`` if the model class is versioned; else ``False``. """ if hasattr(cls, "model_is_versioned"): return cls.model_is_versioned model_class = cls.get_model_class() if hasattr(model_class, "__versioned__"): return True return False
[docs] @classmethod def get_model_version_class(cls): """ Returns the version class for the master model class. Should only be relevant if :meth:`is_versioned()` is true. """ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel return continuum.version_class(cls.get_model_class())
[docs] def should_expose_versions(self): """ Returns boolean indicating whether versioning history should be exposed for the current user. This will return ``True`` unless any of the following are ``False``: * :meth:`is_versioned()` * :meth:`wuttjamaican:wuttjamaican.app.AppHandler.continuum_is_enabled()` * ``self.has_perm("versions")`` - cf. :meth:`has_perm()` :returns: ``True`` if versioning should be exposed for current user; else ``False``. """ if not self.is_versioned(): return False if not self.app.continuum_is_enabled(): return False if not self.has_perm("versions"): return False return True
[docs] def view_versions(self): """ View to list version history for an object. See also :meth:`view_version()`. This usually corresponds to a URL like ``/widgets/XXX/versions/`` where ``XXX`` represents the key/ID for the record. By default, this view is included only if :meth:`is_versioned()` is true. The default view logic will show a "grid" (table) with the record's version history. See also: * :meth:`make_version_grid()` """ instance = self.get_instance() instance_title = self.get_instance_title(instance) grid = self.make_version_grid(instance) # return grid data only, if partial page was requested if self.request.GET.get("partial"): context = grid.get_vue_context() if grid.paginated and grid.paginate_on_backend: context["pager_stats"] = grid.get_vue_pager_stats() return self.json_response(context) index_link = tags.link_to(self.get_index_title(), self.get_index_url()) instance_link = tags.link_to( instance_title, self.get_action_url("view", instance) ) index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( [index_link, instance_link] ) return self.render_to_response( "view_versions", { "index_title_rendered": index_title_rendered, "instance": instance, "instance_title": instance_title, "instance_url": self.get_action_url("view", instance), "grid": grid, }, )
[docs] def make_version_grid(self, instance=None, **kwargs): """ Create and return a grid for use with the :meth:`view_versions()` view. See also related methods, which are called by this one: * :meth:`get_version_grid_key()` * :meth:`get_version_grid_columns()` * :meth:`get_version_grid_data()` * :meth:`configure_version_grid()` :returns: :class:`~wuttaweb.grids.base.Grid` instance """ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel route_prefix = self.get_route_prefix() # instance = kwargs.pop("instance", None) if not instance: instance = self.get_instance() if "key" not in kwargs: kwargs["key"] = self.get_version_grid_key() if "model_class" not in kwargs: kwargs["model_class"] = continuum.transaction_class(self.get_model_class()) if "columns" not in kwargs: kwargs["columns"] = self.get_version_grid_columns() if "data" not in kwargs: kwargs["data"] = self.get_version_grid_data(instance) if "actions" not in kwargs: route = f"{route_prefix}.version" def url(txn, i): # pylint: disable=unused-argument return self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) kwargs["actions"] = [ self.make_grid_action("view", icon="eye", url=url), ] kwargs.setdefault("paginated", True) grid = self.make_grid(**kwargs) self.configure_version_grid(grid) grid.load_settings() return grid
[docs] @classmethod def get_version_grid_key(cls): """ Returns the unique key to be used for the version grid, for caching sort/filter options etc. This is normally called automatically from :meth:`make_version_grid()`. :returns: Grid key as string """ if hasattr(cls, "version_grid_key"): return cls.version_grid_key return f"{cls.get_route_prefix()}.history"
[docs] def get_version_grid_columns(self): """ Returns the default list of version grid column names, for the :meth:`view_versions()` view. This is normally called automatically by :meth:`make_version_grid()`. Subclass may define :attr:`version_grid_columns` for simple cases, or can override this method if needed. :returns: List of string column names """ if hasattr(self, "version_grid_columns"): return self.version_grid_columns return [ "id", "issued_at", "user", "remote_addr", "comment", ]
[docs] def get_version_grid_data(self, instance): """ Returns the grid data query for the :meth:`view_versions()` view. This is normally called automatically by :meth:`make_version_grid()`. Default query will locate SQLAlchemy-Continuum ``transaction`` records which are associated with versions of the given model instance. See also: * :meth:`get_version_joins()` * :meth:`normalize_version_joins()` * :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()` :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance """ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel model_transaction_query, ) model_class = self.get_model_class() txncls = continuum.transaction_class(model_class) query = model_transaction_query(instance, joins=self.normalize_version_joins()) return query.order_by(txncls.issued_at.desc())
[docs] def get_version_joins(self): """ Override this method to declare additional version tables which should be joined when showing the overall revision history for a given model instance. Note that whatever this method returns, will be ran through :meth:`normalize_version_joins()` before being passed along to :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`. :returns: List of version joins info as described below. In the simple scenario where an "extension" table is involved, e.g. a ``UserExtension`` table:: def get_version_joins(self): model = self.app.model return super().get_version_joins() + [ model.UserExtension, ] In the case where a secondary table is "related" to the main model table, but not a standard extension (using the ``User.person`` relationship as example):: def get_version_joins(self): model = self.app.model return super().get_version_joins() + [ (model.Person, "uuid", "person_uuid"), ] See also :meth:`get_version_grid_data()`. """ return []
[docs] def normalize_version_joins(self): """ This method calls :meth:`get_version_joins()` and normalizes the result, which will then get passed along to :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`. Subclass should (generally) not override this, but instead override :meth:`get_version_joins()`. Each element in the return value (list) will be a 3-tuple conforming to what is needed for the query function. See also :meth:`get_version_grid_data()`. :returns: List of version joins info. """ joins = [] for join in self.get_version_joins(): if not isinstance(join, tuple): join = (join, "uuid", "uuid") joins.append(join) return joins
[docs] def configure_version_grid(self, g): """ Configure the grid for the :meth:`view_versions()` view. This is called automatically by :meth:`make_version_grid()`. Default logic applies basic customization to the column labels etc. """ # id g.set_label("id", "TXN ID") # g.set_link("id") # issued_at g.set_label("issued_at", "Changed") g.set_link("issued_at") g.set_sort_defaults("issued_at", "desc") # user g.set_label("user", "Changed by") g.set_link("user") # remote_addr g.set_label("remote_addr", "IP Address") # comment g.set_renderer("comment", self.render_version_comment)
def render_version_comment( # pylint: disable=missing-function-docstring,unused-argument self, txn, key, value ): return txn.meta.get("comment", "")
[docs] def view_version(self): # pylint: disable=too-many-locals """ View to show diff details for a particular object version. See also :meth:`view_versions()`. This usually corresponds to a URL like ``/widgets/XXX/versions/YYY`` where ``XXX`` represents the key/ID for the record and YYY represents a SQLAlchemy-Continuum ``transaction.id``. By default, this view is included only if :meth:`is_versioned()` is true. The default view logic will display a "diff" table showing how the record's values were changed within a transaction. See also: * :func:`wutta-continuum:wutta_continuum.util.model_transaction_query()` * :meth:`get_relevant_versions()` * :class:`~wuttaweb.diffs.VersionDiff` """ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel from wutta_continuum.util import ( # pylint: disable=import-outside-toplevel model_transaction_query, ) instance = self.get_instance() model_class = self.get_model_class() route_prefix = self.get_route_prefix() txncls = continuum.transaction_class(model_class) transactions = model_transaction_query( instance, joins=self.normalize_version_joins() ) txnid = self.request.matchdict["txnid"] txn = transactions.filter(txncls.id == txnid).first() if not txn: raise self.notfound() prev_url = None older = ( transactions.filter(txncls.issued_at <= txn.issued_at) .filter(txncls.id != txnid) .order_by(txncls.issued_at.desc()) .first() ) if older: prev_url = self.request.route_url( f"{route_prefix}.version", uuid=instance.uuid, txnid=older.id ) next_url = None newer = ( transactions.filter(txncls.issued_at >= txn.issued_at) .filter(txncls.id != txnid) .order_by(txncls.issued_at) .first() ) if newer: next_url = self.request.route_url( f"{route_prefix}.version", uuid=instance.uuid, txnid=newer.id ) version_diffs = [ VersionDiff(self.config, version) for version in self.get_relevant_versions(txn, instance) ] index_link = tags.link_to(self.get_index_title(), self.get_index_url()) instance_title = self.get_instance_title(instance) instance_link = tags.link_to( instance_title, self.get_action_url("view", instance) ) history_link = tags.link_to( "history", self.request.route_url(f"{route_prefix}.versions", uuid=instance.uuid), ) index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( [index_link, instance_link, history_link] ) return self.render_to_response( "view_version", { "index_title_rendered": index_title_rendered, "instance": instance, "instance_title": instance_title, "instance_url": self.get_action_url("versions", instance), "transaction": txn, "changed": self.app.render_datetime(txn.issued_at, html=True), "version_diffs": version_diffs, "show_prev_next": True, "prev_url": prev_url, "next_url": next_url, }, )
[docs] def get_relevant_versions(self, transaction, instance): """ Should return all version records pertaining to the given model instance and transaction. This is normally called from :meth:`view_version()`. :param transaction: SQLAlchemy-Continuum ``transaction`` record/instance. :param instance: Instance of the model class. :returns: List of version records. """ import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel session = self.Session() vercls = self.get_model_version_class() versions = [] # first get all versions for the model instance proper versions.extend( session.query(vercls) .filter(vercls.transaction == transaction) .filter(vercls.uuid == instance.uuid) .all() ) # then get all related versions, per declared joins for child_class, foreign_attr, primary_attr in self.normalize_version_joins(): child_vercls = continuum.version_class(child_class) versions.extend( session.query(child_vercls) .filter(child_vercls.transaction == transaction) .filter( getattr(child_vercls, foreign_attr) == getattr(instance, primary_attr) ) ) return versions
############################## # autocomplete methods ##############################
[docs] def autocomplete(self): """ View which accepts a single ``term`` param, and returns a JSON list of autocomplete results to match. By default, this view is included only if :attr:`has_autocomplete` is true. It usually maps to a URL like ``/widgets/autocomplete``. Subclass generally does not need to override this method, but rather should override the others which this calls: * :meth:`autocomplete_data()` * :meth:`autocomplete_normalize()` """ term = self.request.GET.get("term", "") if not term: return [] data = self.autocomplete_data(term) # pylint: disable=assignment-from-none if not data: return [] max_results = 100 # TODO results = [] for obj in data[:max_results]: normal = self.autocomplete_normalize(obj) if normal: results.append(normal) return results
[docs] def autocomplete_data(self, term): # pylint: disable=unused-argument """ Should return the data/query for the "matching" model records, based on autocomplete search term. This is called by :meth:`autocomplete()`. Subclass must override this; default logic returns no data. :param term: String search term as-is from user, e.g. "foo bar". :returns: List of data records, or SQLAlchemy query. """ return None
[docs] def autocomplete_normalize(self, obj): """ Should return a "normalized" version of the given model record, suitable for autocomplete JSON results. This is called by :meth:`autocomplete()`. Subclass may need to override this; default logic is simplistic but will work for basic models. It returns the "autocomplete results" dict for the object:: { 'value': obj.uuid, 'label': str(obj), } The 2 keys shown are required; any other keys will be ignored by the view logic but may be useful on the frontend widget. :param obj: Model record/instance. :returns: Dict of "autocomplete results" format, as shown above. """ return { "value": obj.uuid, "label": str(obj), }
############################## # download methods ##############################
[docs] def download(self): """ View to download a file associated with a model record. This usually corresponds to a URL like ``/widgets/XXX/download`` where ``XXX`` represents the key/ID for the record. By default, this view is included only if :attr:`downloadable` is true. This method will (try to) locate the file on disk, and return it as a file download response to the client. The GET request for this view may contain a ``filename`` query string parameter, which can be used to locate one of various files associated with the model record. This filename is passed to :meth:`download_path()` for locating the file. For instance: ``/widgets/XXX/download?filename=widget-specs.txt`` Subclass normally should not override this method, but rather one of the related methods which are called (in)directly by this one: * :meth:`download_path()` """ obj = self.get_instance() filename = self.request.GET.get("filename", None) path = self.download_path(obj, filename) # pylint: disable=assignment-from-none if not path or not os.path.exists(path): return self.notfound() return self.file_response(path)
[docs] def download_path(self, obj, filename): # pylint: disable=unused-argument """ Should return absolute path on disk, for the given object and filename. Result will be used to return a file response to client. This is called by :meth:`download()`. Default logic always returns ``None``; subclass must override. :param obj: Refefence to the model instance. :param filename: Name of file for which to retrieve the path. :returns: Path to file, or ``None`` if not found. Note that ``filename`` may be ``None`` in which case the "default" file path should be returned, if applicable. If this method returns ``None`` (as it does by default) then the :meth:`download()` view will return a 404 not found response. """ return None
############################## # execute methods ##############################
[docs] def execute(self): """ View to "execute" a model record. Requires a POST request. This usually corresponds to a URL like ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID for the record. By default, this view is included only if :attr:`executable` is true. Probably this is a "rare" view to implement for a model. But there are two notable use cases so far, namely: * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`) * batches (not yet implemented; cf. :doc:`rattail-manual:data/batch/index` in Rattail Manual) The general idea is to take some "irrevocable" action associated with the model record. In the case of upgrades, it is to run the upgrade script. For batches it is to "push live" the data held within the batch. Subclass normally should not override this method, but rather one of the related methods which are called (in)directly by this one: * :meth:`execute_instance()` """ route_prefix = self.get_route_prefix() model_title = self.get_model_title() obj = self.get_instance() # make the progress tracker progress = self.make_progress( f"{route_prefix}.execute", success_msg=f"{model_title} was executed.", success_url=self.get_action_url("view", obj), ) # start thread for execute; show progress page key = self.request.matchdict thread = threading.Thread( target=self.execute_thread, args=(key, self.request.user.uuid), kwargs={"progress": progress}, ) thread.start() return self.render_progress( progress, context={ "instance": obj, }, template=self.execute_progress_template, )
[docs] def execute_instance(self, obj, user, progress=None): """ Perform the actual "execution" logic for a model record. Called by :meth:`execute()`. This method does nothing by default; subclass must override. :param obj: Reference to the model instance. :param user: Reference to the :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who is doing the execute. :param progress: Optional progress indicator factory. """
def execute_thread( # pylint: disable=empty-docstring self, key, user_uuid, progress=None ): """ """ model = self.app.model model_title = self.get_model_title() # nb. use new session, separate from web transaction session = self.app.make_session() # fetch model instance and user for this session obj = self.get_instance(session=session, matchdict=key) user = session.get(model.User, user_uuid) try: self.execute_instance(obj, user, progress=progress) except Exception as error: # pylint: disable=broad-exception-caught session.rollback() log.warning("%s failed to execute: %s", model_title, obj, exc_info=True) if progress: progress.handle_error(error) else: session.commit() if progress: progress.handle_success() finally: session.close() ############################## # configure methods ##############################
[docs] def configure(self, session=None): """ View for configuring aspects of the app which are pertinent to this master view and/or model. By default, this view is included only if :attr:`configurable` is true. It usually maps to a URL like ``/widgets/configure``. The expected workflow is as follows: * user navigates to Configure page * user modifies settings and clicks Save * this view then *deletes* all "known" settings * then it saves user-submitted settings That is unless ``remove_settings`` is requested, in which case settings are deleted but then none are saved. The "known" settings by default include only the "simple" settings. As a general rule, a particular setting should be configurable by (at most) one master view. Some settings may never be exposed at all. But when exposing a setting, careful thought should be given to where it logically/best belongs. Some settings are "simple" and a master view subclass need only provide their basic definitions via :meth:`configure_get_simple_settings()`. If complex settings are needed, subclass must override one or more other methods to achieve the aim(s). See also related methods, used by this one: * :meth:`configure_get_simple_settings()` * :meth:`configure_get_context()` * :meth:`configure_gather_settings()` * :meth:`configure_remove_settings()` * :meth:`configure_save_settings()` """ self.configuring = True config_title = self.get_config_title() # was form submitted? if self.request.method == "POST": # maybe just remove settings if self.request.POST.get("remove_settings"): self.configure_remove_settings(session=session) self.request.session.flash( f"All settings for {config_title} have been removed.", "warning" ) # reload configure page return self.redirect(self.request.current_route_url()) # gather/save settings data = get_form_data(self.request) settings = self.configure_gather_settings(data) self.configure_remove_settings(session=session) self.configure_save_settings(settings, session=session) self.request.session.flash("Settings have been saved.") # reload configure page return self.redirect(self.request.url) # render configure page context = self.configure_get_context() return self.render_to_response("configure", context)
[docs] def configure_get_context( self, simple_settings=None, ): """ Returns the full context dict, for rendering the :meth:`configure()` page template. Default context will include ``simple_settings`` (normalized to just name/value). You may need to override this method, to add additional "complex" settings etc. :param simple_settings: Optional list of simple settings, if already initialized. Otherwise it is retrieved via :meth:`configure_get_simple_settings()`. :returns: Context dict for the page template. """ context = {} # simple settings if simple_settings is None: simple_settings = self.configure_get_simple_settings() if simple_settings: # we got some, so "normalize" each definition to name/value normalized = {} for simple in simple_settings: # name name = simple["name"] # value if "value" in simple: value = simple["value"] elif simple.get("type") is bool: value = self.config.get_bool( name, default=simple.get("default", False) ) else: value = self.config.get(name, default=simple.get("default")) normalized[name] = value # add to template context context["simple_settings"] = normalized return context
[docs] def configure_get_simple_settings(self): """ This should return a list of "simple" setting definitions for the :meth:`configure()` view, which can be handled in a more automatic way. (This is as opposed to some settings which are more complex and must be handled manually; those should not be part of this method's return value.) Basically a "simple" setting is one which can be represented by a single field/widget on the Configure page. The setting definitions returned must each be a dict of "attributes" for the setting. For instance a *very* simple setting might be:: {'name': 'wutta.app_title'} The ``name`` is required, everything else is optional. Here is a more complete example:: { 'name': 'wutta.production', 'type': bool, 'default': False, 'save_if_empty': False, } Note that if specified, the ``default`` should be of the same data type as defined for the setting (``bool`` in the above example). The default ``type`` is ``str``. Normally if a setting's value is effectively null, the setting is removed instead of keeping it in the DB. This behavior can be changed per-setting via the ``save_if_empty`` flag. :returns: List of setting definition dicts as described above. Note that their order does not matter since the template must explicitly define field layout etc. """ return []
[docs] def configure_gather_settings( self, data, simple_settings=None, ): """ Collect the full set of "normalized" settings from user request, so that :meth:`configure()` can save them. Settings are gathered from the given request (e.g. POST) ``data``, but also taking into account what we know based on the simple setting definitions. Subclass may need to override this method if complex settings are required. :param data: Form data submitted via POST request. :param simple_settings: Optional list of simple settings, if already initialized. Otherwise it is retrieved via :meth:`configure_get_simple_settings()`. This method must return a list of normalized settings, similar in spirit to the definition syntax used in :meth:`configure_get_simple_settings()`. However the format returned here is minimal and contains just name/value:: { 'name': 'wutta.app_title', 'value': 'Wutta Wutta', } Note that the ``value`` will always be a string. Also note, whereas it's possible ``data`` will not contain all known settings, the return value *should* (potentially) contain all of them. The one exception is when a simple setting has null value, by default it will not be included in the result (hence, not saved to DB) unless the setting definition has the ``save_if_empty`` flag set. """ settings = [] # simple settings if simple_settings is None: simple_settings = self.configure_get_simple_settings() if simple_settings: # we got some, so "normalize" each definition to name/value for simple in simple_settings: name = simple["name"] if name in data: value = data[name] elif simple.get("type") is bool: # nb. bool false will be *missing* from data value = False else: value = simple.get("default") if simple.get("type") is bool: value = str(bool(value)).lower() elif simple.get("type") is int: value = str(int(value or "0")) elif value is None: value = "" else: value = str(value) # only want to save this setting if we received a # value, or if empty values are okay to save if value or simple.get("save_if_empty"): settings.append({"name": name, "value": value}) return settings
[docs] def configure_remove_settings( self, simple_settings=None, session=None, ): """ Remove all "known" settings from the DB; this is called by :meth:`configure()`. The point of this method is to ensure *all* "known" settings which are managed by this master view, are purged from the DB. The default logic can handle this automatically for simple settings; subclass must override for any complex settings. :param simple_settings: Optional list of simple settings, if already initialized. Otherwise it is retrieved via :meth:`configure_get_simple_settings()`. """ names = [] # simple settings if simple_settings is None: simple_settings = self.configure_get_simple_settings() if simple_settings: names.extend([simple["name"] for simple in simple_settings]) if names: # nb. must avoid self.Session here in case that does not # point to our primary app DB session = session or self.Session() for name in names: self.app.delete_setting(session, name)
[docs] def configure_save_settings(self, settings, session=None): """ Save the given settings to the DB; this is called by :meth:`configure()`. This method expects a list of name/value dicts and will simply save each to the DB, with no "conversion" logic. :param settings: List of normalized setting definitions, as returned by :meth:`configure_gather_settings()`. """ # nb. must avoid self.Session here in case that does not point # to our primary app DB session = session or self.Session() for setting in settings: self.app.save_setting( session, setting["name"], setting["value"], force_create=True )
############################## # grid rendering methods ##############################
[docs] def grid_render_bool(self, record, key, value): # pylint: disable=unused-argument """ Custom grid value renderer for "boolean" fields. This converts a bool value to "Yes" or "No" - unless the value is ``None`` in which case this renders empty string. To use this feature for your grid:: grid.set_renderer('my_bool_field', self.grid_render_bool) """ if value is None: return None return "Yes" if value else "No"
[docs] def grid_render_currency(self, record, key, value, scale=2): """ Custom grid value renderer for "currency" fields. This expects float or decimal values, and will round the decimal as appropriate, and add the currency symbol. :param scale: Number of decimal digits to be displayed; default is 2 places. To use this feature for your grid:: grid.set_renderer('my_currency_field', self.grid_render_currency) # you can also override scale grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4) """ # nb. get new value since the one provided will just be a # (json-safe) *string* if the original type was Decimal value = record[key] if value is None: return None if value < 0: fmt = f"(${{:0,.{scale}f}})" return fmt.format(0 - value) fmt = f"${{:0,.{scale}f}}" return fmt.format(value)
def grid_render_datetime( # pylint: disable=empty-docstring self, record, key, value, fmt=None ): """ """ warnings.warn( "MasterView.grid_render_datetime() is deprecated; " "please use app.render_datetime() directly instead", DeprecationWarning, stacklevel=2, ) # nb. get new value since the one provided will just be a # (json-safe) *string* if the original type was datetime value = record[key] if value is None: return None return value.strftime(fmt or "%Y-%m-%d %I:%M:%S %p")
[docs] def grid_render_enum(self, record, key, value, enum=None): """ Custom grid value renderer for "enum" fields. :param enum: Enum class for the field. This should be an instance of :class:`~python:enum.Enum`. To use this feature for your grid:: from enum import Enum class MyEnum(Enum): ONE = 1 TWO = 2 THREE = 3 grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum) """ if enum: original = record[key] if original: return original.name return value
[docs] def grid_render_notes( # pylint: disable=unused-argument self, record, key, value, maxlen=100 ): """ Custom grid value renderer for "notes" fields. If the given text ``value`` is shorter than ``maxlen`` characters, it is returned as-is. But if it is longer, then it is truncated and an ellispsis is added. The resulting ``<span>`` tag is also given a ``title`` attribute with the original (full) text, so that appears on mouse hover. To use this feature for your grid:: grid.set_renderer('my_notes_field', self.grid_render_notes) # you can also override maxlen grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50) """ if value is None: return None if len(value) < maxlen: return value return HTML.tag("span", title=value, c=f"{value[:maxlen]}...")
############################## # support methods ##############################
[docs] def get_class_hierarchy(self, topfirst=True): """ Convenience to return a list of classes from which the current class inherits. This is a wrapper around :func:`wuttjamaican.util.get_class_hierarchy()`. """ return get_class_hierarchy(self.__class__, topfirst=topfirst)
[docs] def has_perm(self, name): """ Shortcut to check if current user has the given permission. This will automatically add the :attr:`permission_prefix` to ``name`` before passing it on to :func:`~wuttaweb.subscribers.request.has_perm()`. For instance within the :class:`~wuttaweb.views.users.UserView` these give the same result:: self.request.has_perm('users.edit') self.has_perm('edit') So this shortcut only applies to permissions defined for the current master view. The first example above must still be used to check for "foreign" permissions (i.e. any needing a different prefix). """ permission_prefix = self.get_permission_prefix() return self.request.has_perm(f"{permission_prefix}.{name}")
[docs] def has_any_perm(self, *names): """ Shortcut to check if current user has any of the given permissions. This calls :meth:`has_perm()` until one returns ``True``. If none do, returns ``False``. """ for name in names: if self.has_perm(name): return True return False
[docs] def make_button( self, label, variant=None, primary=False, url=None, **kwargs, ): """ Make and return a HTML ``<b-button>`` literal. :param label: Text label for the button. :param variant: This is the "Buefy type" (or "Oruga variant") for the button. Buefy and Oruga represent this differently but this logic expects the Buefy format (e.g. ``is-danger``) and *not* the Oruga format (e.g. ``danger``), despite the param name matching Oruga's terminology. :param type: This param is not advertised in the method signature, but if caller specifies ``type`` instead of ``variant`` it should work the same. :param primary: If neither ``variant`` nor ``type`` are specified, this flag may be used to automatically set the Buefy type to ``is-primary``. This is the preferred method where applicable, since it avoids the Buefy vs. Oruga confusion, and the implementation can change in the future. :param url: Specify this (instead of ``href``) to make the button act like a link. This will yield something like: ``<b-button tag="a" href="{url}">`` :param \\**kwargs: All remaining kwargs are passed to the underlying ``HTML.tag()`` call, so will be rendered as attributes on the button tag. **NB.** You cannot specify a ``tag`` kwarg, for technical reasons. :returns: HTML literal for the button element. Will be something along the lines of: .. code-block:: <b-button type="is-primary" icon-pack="fas" icon-left="hand-pointer"> Click Me </b-button> """ btn_kw = kwargs btn_kw.setdefault("c", label) btn_kw.setdefault("icon_pack", "fas") if "type" not in btn_kw: if variant: btn_kw["type"] = variant elif primary: btn_kw["type"] = "is-primary" if url: btn_kw["href"] = url button = HTML.tag("b-button", **btn_kw) if url: # nb. unfortunately HTML.tag() calls its first arg 'tag' # and so we can't pass a kwarg with that name...so instead # we patch that into place manually button = str(button) button = button.replace("<b-button ", '<b-button tag="a" ') button = HTML.literal(button) return button
[docs] def get_xref_buttons(self, obj): # pylint: disable=unused-argument """ Should return a list of "cross-reference" buttons to be shown when viewing the given object. Default logic always returns empty list; subclass can override as needed. If applicable, this method should do its own permission checks and only include the buttons current user should be allowed to see/use. See also :meth:`make_button()` - example:: def get_xref_buttons(self, product): buttons = [] if self.request.has_perm('external_products.view'): url = self.request.route_url('external_products.view', id=product.external_id) buttons.append(self.make_button("View External", url=url)) return buttons """ return []
[docs] def make_progress(self, key, **kwargs): """ Create and return a :class:`~wuttaweb.progress.SessionProgress` instance, with the given key. This is normally done just before calling :meth:`render_progress()`. """ return SessionProgress(self.request, key, **kwargs)
[docs] def render_progress(self, progress, context=None, template=None): """ Render the progress page, with given template/context. When a view method needs to start a long-running operation, it first starts a thread to do the work, and then it renders the "progress" page. As the operation continues the progress page is updated. When the operation completes (or fails) the user is redirected to the final destination. TODO: should document more about how to do this.. :param progress: Progress indicator instance as returned by :meth:`make_progress()`. :returns: A :term:`response` with rendered progress page. """ template = template or "/progress.mako" context = context or {} context["progress"] = progress return render_to_response(template, context, request=self.request)
[docs] def render_to_response(self, template, context): """ Locate and render an appropriate template, with the given context, and return a :term:`response`. The specified ``template`` should be only the "base name" for the template - e.g. ``'index'`` or ``'edit'``. This method will then try to locate a suitable template file, based on values from :meth:`get_template_prefix()` and :meth:`get_fallback_templates()`. In practice this *usually* means two different template paths will be attempted, e.g. if ``template`` is ``'edit'`` and :attr:`template_prefix` is ``'/widgets'``: * ``/widgets/edit.mako`` * ``/master/edit.mako`` The first template found to exist will be used for rendering. It then calls :func:`pyramid:pyramid.renderers.render_to_response()` and returns the result. :param template: Base name for the template. :param context: Data dict to be used as template context. :returns: Response object containing the rendered template. """ defaults = { "master": self, "route_prefix": self.get_route_prefix(), "index_title": self.get_index_title(), "index_url": self.get_index_url(), "model_title": self.get_model_title(), "model_title_plural": self.get_model_title_plural(), "config_title": self.get_config_title(), } # merge defaults + caller-provided context defaults.update(context) context = defaults # add crud flags if we have an instance if "instance" in context: instance = context["instance"] if "instance_title" not in context: context["instance_title"] = self.get_instance_title(instance) if "instance_editable" not in context: context["instance_editable"] = self.is_editable(instance) if "instance_deletable" not in context: context["instance_deletable"] = self.is_deletable(instance) # supplement context further if needed context = self.get_template_context(context) # first try the template path most specific to this view page_templates = self.get_page_templates(template) mako_path = page_templates[0] try: return render_to_response(mako_path, context, request=self.request) except IOError: # failing that, try one or more fallback templates for fallback in page_templates[1:]: try: return render_to_response(fallback, context, request=self.request) except IOError: pass # if we made it all the way here, then we found no # templates at all, in which case re-attempt the first and # let that error raise on up return render_to_response(mako_path, context, request=self.request)
[docs] def get_template_context(self, context): """ This method should return the "complete" context for rendering the current view template. Default logic for this method returns the given context unchanged. You may wish to override to pass extra context to the view template. Check :attr:`viewing` and similar, or ``request.current_route_name`` etc. in order to add extra context only for certain view templates. :params: context: The context dict we have so far, auto-provided by the master view logic. :returns: Final context dict for the template. """ return context
[docs] def get_page_templates(self, template): """ Returns a list of all templates which can be attempted, to render the current page. This is called by :meth:`render_to_response()`. The list should be in order of preference, e.g. the first entry will be the most "specific" template, with subsequent entries becoming more generic. In practice this method defines the first entry but calls :meth:`get_fallback_templates()` for the rest. :param template: Base name for a template (without prefix), e.g. ``'view'``. :returns: List of template paths to be tried, based on the specified template. For instance if ``template`` is ``'view'`` this will (by default) return:: [ '/widgets/view.mako', '/master/view.mako', ] """ template_prefix = self.get_template_prefix() page_templates = [f"{template_prefix}/{template}.mako"] page_templates.extend(self.get_fallback_templates(template)) return page_templates
[docs] def get_fallback_templates(self, template): """ Returns a list of "fallback" template paths which may be attempted for rendering the current page. See also :meth:`get_page_templates()`. :param template: Base name for a template (without prefix), e.g. ``'view'``. :returns: List of template paths to be tried, based on the specified template. For instance if ``template`` is ``'view'`` this will (by default) return:: ['/master/view.mako'] """ return [f"/master/{template}.mako"]
[docs] def get_index_title(self): """ Returns the main index title for the master view. By default this returns the value from :meth:`get_model_title_plural()`. Subclass may override as needed. """ return self.get_model_title_plural()
[docs] def get_index_url(self, **kwargs): """ Returns the URL for master's :meth:`index()` view. NB. this returns ``None`` if :attr:`listable` is false. """ if self.listable: route_prefix = self.get_route_prefix() return self.request.route_url(route_prefix, **kwargs) return None
[docs] def set_labels(self, obj): """ Set label overrides on a form or grid, based on what is defined by the view class and its parent class(es). This is called automatically from :meth:`configure_grid()` and :meth:`configure_form()`. This calls :meth:`collect_labels()` to find everything, then it assigns the labels using one of (based on ``obj`` type): * :func:`wuttaweb.forms.base.Form.set_label()` * :func:`wuttaweb.grids.base.Grid.set_label()` :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a :class:`~wuttaweb.forms.base.Form` instance. """ labels = self.collect_labels() for key, label in labels.items(): obj.set_label(key, label)
[docs] def collect_labels(self): """ Collect all labels defined by the view class and/or its parents. A master view can declare labels via class-level attribute, like so:: from wuttaweb.views import MasterView class WidgetView(MasterView): labels = { 'id': "Widget ID", 'serial_no': "Serial Number", } All such labels, defined by any class from which the master view inherits, will be returned. However if the same label key is defined by multiple classes, the "subclass" always wins. Labels defined in this way will apply to both forms and grids. See also :meth:`set_labels()`. :returns: Dict of all labels found. """ labels = {} hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, "labels"): labels.update(cls.labels) return labels
[docs] def make_model_grid( self, session=None, **kwargs ): # pylint: disable=too-many-branches,too-many-statements """ Create and return a :class:`~wuttaweb.grids.base.Grid` instance for use with the :meth:`index()` view. See also related methods, which are called by this one: * :meth:`get_grid_key()` * :meth:`get_grid_columns()` * :meth:`get_grid_data()` * :meth:`configure_grid()` """ route_prefix = self.get_route_prefix() if "key" not in kwargs: kwargs["key"] = self.get_grid_key() if "model_class" not in kwargs: model_class = self.get_model_class() if model_class: kwargs["model_class"] = model_class if "columns" not in kwargs: kwargs["columns"] = self.get_grid_columns() if "data" not in kwargs: kwargs["data"] = self.get_grid_data( columns=kwargs["columns"], session=session ) if "actions" not in kwargs: actions = [] # TODO: should split this off into index_get_grid_actions() ? if self.viewable and self.has_perm("view"): actions.append( self.make_grid_action( "view", icon="eye", url=self.get_action_url_view ) ) if self.editable and self.has_perm("edit"): actions.append( self.make_grid_action( "edit", icon="edit", url=self.get_action_url_edit ) ) if self.deletable and self.has_perm("delete"): actions.append( self.make_grid_action( "delete", icon="trash", url=self.get_action_url_delete, link_class="has-text-danger", ) ) kwargs["actions"] = actions mergeable = self.mergeable and self.has_perm("merge") if "tools" not in kwargs: tools = [] # show totals if self.has_grid_totals: button = self.make_button( "{{ gridTotalsFetching ? 'Working, please wait...' : 'Show Totals' }}", icon_left="calculator", **{ "v-if": "!gridTotalsHTML.length", ":disabled": "gridTotalsFetching", "@click": "gridTotalsFetch()", }, ) display = HTML.tag( "div", class_="control", style="margin: 0 0.5rem;", **{ "v-if": "gridTotalsHTML.length", "v-html": "gridTotalsHTML", }, ) wrapper = HTML.tag("div", c=[button, display]) tools.append(("show-totals", wrapper)) # delete-bulk if self.deletable_bulk and self.has_perm("delete_bulk"): tools.append(("delete-results", self.delete_bulk_make_button())) # merge if mergeable: hidden = tags.hidden("uuids", **{":value": "checkedUUIDs"}) button = self.make_button( '{{ mergeSubmitting ? "Working, please wait..." : "Merge 2 records" }}', primary=True, native_type="submit", icon_left="object-ungroup", **{":disabled": "mergeSubmitting || checkedRows.length != 2"}, ) csrf = render_csrf_token(self.request) html = ( tags.form( self.request.route_url(f"{route_prefix}.merge"), **{"@submit": "mergeSubmitting = true"}, ) + csrf + hidden + button + tags.end_form() ) tools.append(("merge", html)) kwargs["tools"] = tools kwargs.setdefault("checkable", self.checkable or mergeable) if hasattr(self, "grid_row_class"): kwargs.setdefault("row_class", self.grid_row_class) kwargs.setdefault("filterable", self.filterable) kwargs.setdefault("filter_defaults", self.filter_defaults) kwargs.setdefault("sortable", self.sortable) kwargs.setdefault("sort_on_backend", self.sort_on_backend) kwargs.setdefault("sort_defaults", self.sort_defaults) kwargs.setdefault("paginated", self.paginated) kwargs.setdefault("paginate_on_backend", self.paginate_on_backend) grid = self.make_grid(**kwargs) self.configure_grid(grid) grid.load_settings() return grid
[docs] def get_grid_columns(self): """ Returns the default list of grid column names, for the :meth:`index()` view. This is called by :meth:`make_model_grid()`; in the resulting :class:`~wuttaweb.grids.base.Grid` instance, this becomes :attr:`~wuttaweb.grids.base.Grid.columns`. This method may return ``None``, in which case the grid may (try to) generate its own default list. Subclass may define :attr:`grid_columns` for simple cases, or can override this method if needed. Also note that :meth:`configure_grid()` may be used to further modify the final column set, regardless of what this method returns. So a common pattern is to declare all "supported" columns by setting :attr:`grid_columns` but then optionally remove or replace some of those within :meth:`configure_grid()`. """ if hasattr(self, "grid_columns"): return self.grid_columns return None
[docs] def get_grid_data( # pylint: disable=unused-argument self, columns=None, session=None ): """ Returns the grid data for the :meth:`index()` view. This is called by :meth:`make_model_grid()`; in the resulting :class:`~wuttaweb.grids.base.Grid` instance, this becomes :attr:`~wuttaweb.grids.base.Grid.data`. Default logic will call :meth:`get_query()` and if successful, return the list from ``query.all()``. Otherwise returns an empty list. Subclass should override as needed. """ query = self.get_query(session=session) if query: return query return []
[docs] def get_query(self, session=None): """ Returns the main SQLAlchemy query object for the :meth:`index()` view. This is called by :meth:`get_grid_data()`. Default logic for this method returns a "plain" query on the :attr:`model_class` if that is defined; otherwise ``None``. """ model_class = self.get_model_class() if model_class: session = session or self.Session() return session.query(model_class) return None
[docs] def configure_grid(self, grid): """ Configure the grid for the :meth:`index()` view. This is called by :meth:`make_model_grid()`. There is minimal default logic here; subclass should override as needed. The ``grid`` param will already be "complete" and ready to use as-is, but this method can further modify it based on request details etc. """ if "uuid" in grid.columns: grid.columns.remove("uuid") self.set_labels(grid)
# TODO: i thought this was a good idea but if so it # needs a try/catch in case of no model class # for key in self.get_model_key(): # grid.set_link(key)
[docs] def get_instance(self, session=None, matchdict=None): """ This should return the appropriate model instance, based on the ``matchdict`` of model keys. Normally this is called with no arguments, in which case the :attr:`pyramid:pyramid.request.Request.matchdict` is used, and will return the "current" model instance based on the request (route/params). If a ``matchdict`` is provided then that is used instead, to obtain the model keys. In the simple/common example of a "native" model in WuttaWeb, this would look like:: keys = {'uuid': '38905440630d11ef9228743af49773a4'} obj = self.get_instance(matchdict=keys) Although some models may have different, possibly composite key names to use instead. The specific keys this logic is expecting are the same as returned by :meth:`get_model_key()`. If this method is unable to locate the instance, it should raise a 404 error, i.e. :meth:`~wuttaweb.views.base.View.notfound()`. Default implementation of this method should work okay for views which define a :attr:`model_class`. For other views however it will raise ``NotImplementedError``, so subclass may need to define. .. warning:: If you are defining this method for a subclass, please note this point regarding the 404 "not found" logic. It is *not* enough to simply *return* this 404 response, you must explicitly *raise* the error. For instance:: def get_instance(self, **kwargs): # ..try to locate instance.. obj = self.locate_instance_somehow() if not obj: # NB. THIS MAY NOT WORK AS EXPECTED #return self.notfound() # nb. should always do this in get_instance() raise self.notfound() This lets calling code not have to worry about whether or not this method might return ``None``. It can safely assume it will get back a model instance, or else a 404 will kick in and control flow goes elsewhere. """ model_class = self.get_model_class() if model_class: session = session or self.Session() matchdict = matchdict or self.request.matchdict def filtr(query, model_key): key = matchdict[model_key] query = query.filter(getattr(self.model_class, model_key) == key) return query query = session.query(model_class) for key in self.get_model_key(): query = filtr(query, key) try: return query.one() except orm.exc.NoResultFound: pass raise self.notfound() raise NotImplementedError( "you must define get_instance() method " f" for view class: {self.__class__}" )
[docs] def get_instance_title(self, instance): """ Return the human-friendly "title" for the instance, to be used in the page title when viewing etc. Default logic returns the value from ``str(instance)``; subclass may override if needed. """ return str(instance) or "(no title)"
[docs] def get_action_route_kwargs(self, obj): """ Get a dict of route kwargs for the given object. This is called from :meth:`get_action_url()` and must return kwargs suitable for use with ``request.route_url()``. In practice this should return a dict which has keys for each field from :meth:`get_model_key()` and values which come from the object. :param obj: Model instance object. :returns: The dict of route kwargs for the object. """ try: return {key: obj[key] for key in self.get_model_key()} except TypeError: return {key: getattr(obj, key) for key in self.get_model_key()}
[docs] def get_action_url(self, action, obj, **kwargs): """ Generate an "action" URL for the given model instance. This is a shortcut which generates a route name based on :meth:`get_route_prefix()` and the ``action`` param. It calls :meth:`get_action_route_kwargs()` and then passes those along with route name to ``request.route_url()``, and returns the result. :param action: String name for the action, which corresponds to part of some named route, e.g. ``'view'`` or ``'edit'``. :param obj: Model instance object. :param \\**kwargs: Additional kwargs to be passed to ``request.route_url()``, if needed. """ kw = self.get_action_route_kwargs(obj) kw.update(kwargs) route_prefix = self.get_route_prefix() return self.request.route_url(f"{route_prefix}.{action}", **kw)
[docs] def get_action_url_view(self, obj, i): # pylint: disable=unused-argument """ Returns the "view" grid action URL for the given object. Most typically this is like ``/widgets/XXX`` where ``XXX`` represents the object's key/ID. Calls :meth:`get_action_url()` under the hood. """ return self.get_action_url("view", obj)
[docs] def get_action_url_edit(self, obj, i): # pylint: disable=unused-argument """ Returns the "edit" grid action URL for the given object, if applicable. Most typically this is like ``/widgets/XXX/edit`` where ``XXX`` represents the object's key/ID. This first calls :meth:`is_editable()` and if that is false, this method will return ``None``. Calls :meth:`get_action_url()` to generate the true URL. """ if self.is_editable(obj): return self.get_action_url("edit", obj) return None
[docs] def get_action_url_delete(self, obj, i): # pylint: disable=unused-argument """ Returns the "delete" grid action URL for the given object, if applicable. Most typically this is like ``/widgets/XXX/delete`` where ``XXX`` represents the object's key/ID. This first calls :meth:`is_deletable()` and if that is false, this method will return ``None``. Calls :meth:`get_action_url()` to generate the true URL. """ if self.is_deletable(obj): return self.get_action_url("delete", obj) return None
[docs] def is_editable(self, obj): # pylint: disable=unused-argument """ Returns a boolean indicating whether "edit" should be allowed for the given model instance (and for current user). By default this always return ``True``; subclass can override if needed. Note that the use of this method implies :attr:`editable` is true, so the method does not need to check that flag. """ return True
[docs] def is_deletable(self, obj): # pylint: disable=unused-argument """ Returns a boolean indicating whether "delete" should be allowed for the given model instance (and for current user). By default this always return ``True``; subclass can override if needed. Note that the use of this method implies :attr:`deletable` is true, so the method does not need to check that flag. """ return True
[docs] def make_model_form(self, model_instance=None, fields=None, **kwargs): """ Make a form for the "model" represented by this subclass. This method is normally called by all CRUD views: * :meth:`create()` * :meth:`view()` * :meth:`edit()` * :meth:`delete()` The form need not have a ``model_instance``, as in the case of :meth:`create()`. And it can be readonly as in the case of :meth:`view()` and :meth:`delete()`. If ``fields`` are not provided, :meth:`get_form_fields()` is called. Usually a subclass will define :attr:`form_fields` but it's only required if :attr:`model_class` is not set. Then :meth:`configure_form()` is called, so subclass can go crazy with that as needed. :param model_instance: Model instance/record with which to initialize the form data. Not needed for "create" forms. :param fields: Optional fields list for the form. :returns: :class:`~wuttaweb.forms.base.Form` instance """ if "model_class" not in kwargs: model_class = self.get_model_class() if model_class: kwargs["model_class"] = model_class kwargs["model_instance"] = model_instance if not fields: fields = self.get_form_fields() if fields: kwargs["fields"] = fields form = self.make_form(**kwargs) self.configure_form(form) return form
[docs] def get_form_fields(self): """ Returns the initial list of field names for the model form. This is called by :meth:`make_model_form()`; in the resulting :class:`~wuttaweb.forms.base.Form` instance, this becomes :attr:`~wuttaweb.forms.base.Form.fields`. This method may return ``None``, in which case the form may (try to) generate its own default list. Subclass may define :attr:`form_fields` for simple cases, or can override this method if needed. Note that :meth:`configure_form()` may be used to further modify the final field list, regardless of what this method returns. So a common pattern is to declare all "supported" fields by setting :attr:`form_fields` but then optionally remove or replace some in :meth:`configure_form()`. """ if hasattr(self, "form_fields"): return self.form_fields return None
[docs] def configure_form(self, form): """ Configure the given model form, as needed. This is called by :meth:`make_model_form()` - for multiple CRUD views (create, view, edit, delete, possibly others). The default logic here does just one thing: when "editing" (i.e. in :meth:`edit()` view) then all fields which are part of the :attr:`model_key` will be marked via :meth:`set_readonly()` so the user cannot change primary key values for a record. Subclass may override as needed. The ``form`` param will already be "complete" and ready to use as-is, but this method can further modify it based on request details etc. """ form.remove("uuid") self.set_labels(form) # mark key fields as readonly to prevent edit. see also # related comments in the objectify() method if self.editing: for key in self.get_model_key(): form.set_readonly(key)
[docs] def objectify(self, form): """ Must return a "model instance" object which reflects the validated form data. In simple cases this may just return the :attr:`~wuttaweb.forms.base.Form.validated` data dict. When dealing with SQLAlchemy models it would return a proper mapped instance, creating it if necessary. This is called by various other form-saving methods: * :meth:`save_create_form()` * :meth:`save_edit_form()` * :meth:`create_row_save_form()` See also :meth:`persist()`. :param form: Reference to the *already validated* :class:`~wuttaweb.forms.base.Form` object. See the form's :attr:`~wuttaweb.forms.base.Form.validated` attribute for the data. """ # ColanderAlchemy schema has an objectify() method which will # return a populated model instance schema = form.get_schema() if hasattr(schema, "objectify"): return schema.objectify(form.validated, context=form.model_instance) # at this point we likely have no model class, so have to # assume we're operating on a simple dict record. we (mostly) # want to return that as-is, unless subclass overrides. data = dict(form.validated) # nb. we have a unique scenario when *editing* for a simple # dict record (no model class). we mark the key fields as # readonly in configure_form(), so they aren't part of the # data here, but we need to add them back for sake of # e.g. generating the 'view' route kwargs for redirect. if self.editing: obj = self.get_instance() for key in self.get_model_key(): if key not in data: data[key] = obj[key] return data
[docs] def persist(self, obj, session=None): """ If applicable, this method should persist ("save") the given object's data (e.g. to DB), creating or updating it as needed. This is part of the "submit form" workflow; ``obj`` should be a model instance which already reflects the validated form data. Note that there is no default logic here, subclass must override if needed. :param obj: Model instance object as produced by :meth:`objectify()`. See also :meth:`save_create_form()` and :meth:`save_edit_form()`, which call this method. """ model = self.app.model model_class = self.get_model_class() if model_class and issubclass(model_class, model.Base): # add sqlalchemy model to session session = session or self.Session() session.add(obj)
[docs] def do_thread_body( # pylint: disable=too-many-arguments,too-many-positional-arguments self, func, args, kwargs, onerror=None, session=None, progress=None ): """ Generic method to invoke for thread operations. :param func: Callable which performs the actual logic. This will be wrapped with a try/except statement for error handling. :param args: Tuple of positional arguments to pass to the ``func`` callable. :param kwargs: Dict of keyword arguments to pass to the ``func`` callable. :param onerror: Optional callback to invoke if ``func`` raises an error. It should not expect any arguments. :param session: Optional :term:`db session` in effect. Note that if supplied, it will be *committed* (or rolled back on error) and *closed* by this method. If you need more specialized handling, do not use this method (or don't specify the ``session``). :param progress: Optional progress factory. If supplied, this is assumed to be a :class:`~wuttaweb.progress.SessionProgress` instance, and it will be updated per success or failure of ``func`` invocation. """ try: func(*args, **kwargs) except Exception as error: # pylint: disable=broad-exception-caught if session: session.rollback() if onerror: onerror() else: log.warning("failed to invoke thread callable: %s", func, exc_info=True) if progress: progress.handle_error(error) else: if session: session.commit() if progress: progress.handle_success() finally: if session: session.close()
############################## # merge methods ##############################
[docs] def merge(self): """ View for merging two records. By default, this view is included only if :attr:`mergeable` is true. It usually maps to a URL like ``/widgets/merge``. A POST request must be used for this view; otherwise it will redirect to the :meth:`index()` view. The POST data must specify a ``uuids`` param string in ``"removing_uuid,keeping_uuid"`` format. The user is first shown a "diff" with the removing/keeping/final data records, as simple preview. They can swap removing vs. keeping if needed, and when satisfied they can "execute" the merge. See also related methods, used by this one: * :meth:`merge_validate_and_execute()` * :meth:`merge_get_data()` * :meth:`merge_get_final_data()` """ if self.request.method != "POST": return self.redirect(self.get_index_url()) session = self.Session() model_class = self.get_model_class() # load records to be kept/removed removing = keeping = None uuids = self.request.POST.get("uuids", "").split(",") if len(uuids) == 2: uuid1, uuid2 = uuids try: uuid1 = UUID(uuid1) uuid2 = UUID(uuid2) except ValueError: pass else: removing = session.get(model_class, uuid1) keeping = session.get(model_class, uuid2) # redirect to listing if record(s) not found if not (removing and keeping): raise self.redirect(self.get_index_url()) # maybe execute merge if self.request.POST.get("execute-merge") == "true": if self.merge_validate_and_execute(removing, keeping): return self.redirect(self.get_action_url("view", keeping)) removing_data = self.merge_get_data(removing) keeping_data = self.merge_get_data(keeping) diff = MergeDiff( self.config, removing_data, keeping_data, self.merge_get_final_data(removing_data, keeping_data), fields=self.merge_get_all_fields(), ) context = {"removing": removing, "keeping": keeping, "diff": diff} return self.render_to_response("merge", context)
[docs] def merge_get_simple_fields(self): """ Return the list of "simple" fields for the merge. These "simple" fields will not have any special handling for the merge. In other words the "removing" record values will be ignored and the "keeping" record values will remain in place, without modification. If the view class defines :attr:`merge_simple_fields`, that list is returned as-is. Otherwise the list of columns from :attr:`model_class` is returned. :returns: List of simple field names. """ if self.merge_simple_fields: return list(self.merge_simple_fields) mapper = sa.inspect(self.get_model_class()) fields = mapper.columns.keys() return fields
[docs] def merge_get_additive_fields(self): """ Return the list of "additive" fields for the merge. Values from the removing/keeping record will be conceptually added together, for each of these fields. If the view class defines :attr:`merge_additive_fields`, that list is returned as-is. Otherwise an empty list is returned. :returns: List of additive field names. """ if self.merge_additive_fields: return list(self.merge_additive_fields) return []
[docs] def merge_get_coalesce_fields(self): """ Return the list of "coalesce" fields for the merge. Values from the removing/keeping record will be conceptually "coalesced" for each of these fields. If the view class defines :attr:`merge_coalesce_fields`, that list is returned as-is. Otherwise an empty list is returned. :returns: List of coalesce field names. """ if self.merge_coalesce_fields: return list(self.merge_coalesce_fields) return []
[docs] def merge_get_all_fields(self): """ Return the list of *all* fields for the merge. This will call each of the following methods to collect all field names, then it returns the full *sorted* list. * :meth:`merge_get_additive_fields()` * :meth:`merge_get_coalesce_fields()` * :meth:`merge_get_simple_fields()` :returns: Sorted list of all field names. """ fields = set() fields.update(self.merge_get_simple_fields()) fields.update(self.merge_get_additive_fields()) fields.update(self.merge_get_coalesce_fields()) return sorted(fields)
[docs] def merge_get_data(self, obj): """ Return a data dict for the given object, which will be either the "removing" or "keeping" record for the merge. By default this calls :meth:`merge_get_all_fields()` and then for each field, calls ``getattr()`` on the object. Subclass can override as needed for custom logic. :param obj: Reference to model/record instance. :returns: Data dict with all field values. """ return {f: getattr(obj, f, None) for f in self.merge_get_all_fields()}
[docs] def merge_get_final_data(self, removing, keeping): """ Return the "final" data dict for the merge. The result will be identical to the "keeping" record, for all "simple" fields. However the "additive" and "coalesce" fields are handled specially per their nature, in which case those final values may or may not match the "keeping" record. :param removing: Data dict for the "removing" record. :param keeping: Data dict for the "keeping" record. :returns: Data dict with all "final" field values. See also: * :meth:`merge()` * :meth:`merge_get_additive_fields()` * :meth:`merge_get_coalesce_fields()` """ final = dict(keeping) for field in self.merge_get_additive_fields(): if isinstance(keeping[field], list): final[field] = sorted(set(removing[field] + keeping[field])) else: final[field] = removing[field] + keeping[field] for field in self.merge_get_coalesce_fields(): if removing[field] is not None and keeping[field] is None: final[field] = removing[field] elif removing[field] and not keeping[field]: final[field] = removing[field] return final
[docs] def merge_validate_and_execute(self, removing, keeping): """ Validate and execute a merge for the two given records. It is called from :meth:`merge()`. This calls :meth:`merge_why_not()` and if that does not yield a reason to prevent the merge, then calls :meth:`merge_execute()`. If there was a reason not to merge, or if an error occurs during merge execution, a flash warning/error message is set to notify the user what happened. :param removing: Reference to the "removing" model instance/record. :param keeping: Reference to the "keeping" model instance/record. :returns: Boolean indicating whether merge execution completed successfully. """ session = self.Session() # validate the merge if reason := self.merge_why_not(removing, keeping): warning = HTML.tag( "p", class_="block", c="Merge cannot proceed:" ) + HTML.tag("p", class_="block", c=reason) self.request.session.flash(warning, "warning") return False # execute the merge removed_title = str(removing) try: self.merge_execute(removing, keeping) session.flush() except Exception as err: # pylint: disable=broad-exception-caught session.rollback() log.warning("merge failed", exc_info=True) warning = HTML.tag("p", class_="block", c="Merge failed:") + HTML.tag( "p", class_="block", c=self.app.render_error(err) ) self.request.session.flash(warning, "error") return False self.request.session.flash(f"{removed_title} has been merged into {keeping}") return True
[docs] def merge_why_not(self, removing, keeping): # pylint: disable=unused-argument """ Can return a "reason" why the two given records should not be merged. This returns ``None`` by default, indicating the merge is allowed. Subclass can override as needed for custom logic. See also :meth:`merge_validate_and_execute()`. :param removing: Reference to the "removing" model instance/record. :param keeping: Reference to the "keeping" model instance/record. :returns: Reason not to merge (as string), or ``None``. """ return None
[docs] def merge_execute(self, removing, keeping): # pylint: disable=unused-argument """ Execute the actual merge for the two given objects. Default logic simply deletes the "removing" record. Subclass can override as needed for custom logic. See also :meth:`merge_validate_and_execute()`. :param removing: Reference to the "removing" model instance/record. :param keeping: Reference to the "keeping" model instance/record. """ session = self.Session() # nb. default "merge" does not update kept object! session.delete(removing)
############################## # row methods ##############################
[docs] def get_rows_title(self): """ Returns the display title for model **rows** grid, if applicable/desired. Only relevant if :attr:`has_rows` is true. There is no default here, but subclass may override by assigning :attr:`rows_title`. """ if hasattr(self, "rows_title"): return self.rows_title return self.get_row_model_title_plural()
[docs] def get_row_parent(self, row): """ This must return the parent object for the given child row. Only relevant if :attr:`has_rows` is true. Default logic is not implemented; subclass must override. """ raise NotImplementedError
[docs] def make_row_model_grid(self, obj, **kwargs): """ Create and return a grid for a record's **rows** data, for use in :meth:`view()`. Only applicable if :attr:`has_rows` is true. :param obj: Current model instance for which rows data is being displayed. :returns: :class:`~wuttaweb.grids.base.Grid` instance for the rows data. See also related methods, which are called by this one: * :meth:`get_row_grid_key()` * :meth:`get_row_grid_columns()` * :meth:`get_row_grid_data()` * :meth:`configure_row_grid()` """ if "key" not in kwargs: kwargs["key"] = self.get_row_grid_key() if "model_class" not in kwargs: model_class = self.get_row_model_class() if model_class: kwargs["model_class"] = model_class if "columns" not in kwargs: kwargs["columns"] = self.get_row_grid_columns() if "data" not in kwargs: kwargs["data"] = self.get_row_grid_data(obj) kwargs.setdefault("filterable", self.rows_filterable) kwargs.setdefault("filter_defaults", self.rows_filter_defaults) kwargs.setdefault("sortable", self.rows_sortable) kwargs.setdefault("sort_on_backend", self.rows_sort_on_backend) kwargs.setdefault("sort_defaults", self.rows_sort_defaults) kwargs.setdefault("paginated", self.rows_paginated) kwargs.setdefault("paginate_on_backend", self.rows_paginate_on_backend) if "actions" not in kwargs: actions = [] if self.rows_viewable: actions.append( self.make_grid_action( "view", icon="eye", url=self.get_row_action_url_view ) ) if actions: kwargs["actions"] = actions grid = self.make_grid(**kwargs) self.configure_row_grid(grid) grid.load_settings() return grid
[docs] def get_row_grid_key(self): """ Returns the (presumably) unique key to be used for the **rows** grid in :meth:`view()`. Only relevant if :attr:`has_rows` is true. This is called from :meth:`make_row_model_grid()`; in the resulting grid, this becomes :attr:`~wuttaweb.grids.base.Grid.key`. Whereas you can define :attr:`grid_key` for the main grid, the row grid key is always generated dynamically. This incorporates the current record key (whose rows are in the grid) so that the rows grid for each record is unique. """ parts = [self.get_grid_key()] for key in self.get_model_key(): parts.append(str(self.request.matchdict[key])) return ".".join(parts)
[docs] def get_row_grid_columns(self): """ Returns the default list of column names for the **rows** grid, for use in :meth:`view()`. Only relevant if :attr:`has_rows` is true. This is called by :meth:`make_row_model_grid()`; in the resulting grid, this becomes :attr:`~wuttaweb.grids.base.Grid.columns`. This method may return ``None``, in which case the grid may (try to) generate its own default list. Subclass may define :attr:`row_grid_columns` for simple cases, or can override this method if needed. Also note that :meth:`configure_row_grid()` may be used to further modify the final column set, regardless of what this method returns. So a common pattern is to declare all "supported" columns by setting :attr:`row_grid_columns` but then optionally remove or replace some of those within :meth:`configure_row_grid()`. """ if hasattr(self, "row_grid_columns"): return self.row_grid_columns return None
[docs] def get_row_grid_data(self, obj): """ Returns the data for the **rows** grid, for use in :meth:`view()`. Only relevant if :attr:`has_rows` is true. This is called by :meth:`make_row_model_grid()`; in the resulting grid, this becomes :attr:`~wuttaweb.grids.base.Grid.data`. Default logic not implemented; subclass must define this. """ raise NotImplementedError
[docs] def configure_row_grid(self, grid): """ Configure the **rows** grid for use in :meth:`view()`. Only relevant if :attr:`has_rows` is true. This is called by :meth:`make_row_model_grid()`. There is minimal default logic here; subclass should override as needed. The ``grid`` param will already be "complete" and ready to use as-is, but this method can further modify it based on request details etc. """ grid.remove("uuid") self.set_row_labels(grid)
[docs] def set_row_labels(self, obj): """ Set label overrides on a **row** form or grid, based on what is defined by the view class and its parent class(es). This is called automatically from :meth:`configure_row_grid()` and :meth:`configure_row_form()`. This calls :meth:`collect_row_labels()` to find everything, then it assigns the labels using one of (based on ``obj`` type): * :func:`wuttaweb.forms.base.Form.set_label()` * :func:`wuttaweb.grids.base.Grid.set_label()` :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a :class:`~wuttaweb.forms.base.Form` instance. """ labels = self.collect_row_labels() for key, label in labels.items(): obj.set_label(key, label)
[docs] def collect_row_labels(self): """ Collect all **row** labels defined within the view class hierarchy. This is called by :meth:`set_row_labels()`. :returns: Dict of all labels found. """ labels = {} hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, "row_labels"): labels.update(cls.row_labels) return labels
[docs] def get_row_action_url_view(self, row, i): """ Must return the "view" action url for the given row object. Only relevant if :attr:`rows_viewable` is true. There is no default logic; subclass must override if needed. """ raise NotImplementedError
[docs] def create_row(self): """ View to create a new "child row" record. This usually corresponds to a URL like ``/widgets/XXX/new-row``. By default, this view is included only if :attr:`rows_creatable` is true. The default "create row" view logic will show a form with field widgets, allowing user to submit new values which are then persisted to the DB (assuming typical SQLAlchemy model). Subclass normally should not override this method, but rather one of the related methods which are called (in)directly by this one: * :meth:`make_row_model_form()` * :meth:`configure_row_form()` * :meth:`create_row_save_form()` * :meth:`redirect_after_create_row()` """ self.creating = True parent = self.get_instance() parent_url = self.get_action_url("view", parent) form = self.make_row_model_form(cancel_url_fallback=parent_url) if form.validate(): result = self.create_row_save_form(form) return self.redirect_after_create_row(result) index_link = tags.link_to(self.get_index_title(), self.get_index_url()) parent_link = tags.link_to(self.get_instance_title(parent), parent_url) index_title_rendered = HTML.literal("<span>&nbsp;&raquo;</span>").join( [index_link, parent_link] ) context = { "form": form, "index_title_rendered": index_title_rendered, "row_model_title": self.get_row_model_title(), } return self.render_to_response("create_row", context)
[docs] def create_row_save_form(self, form): """ This method converts the validated form data to a row model instance, and then saves the result to DB. It is called by :meth:`create_row()`. :returns: The resulting row model instance, as produced by :meth:`objectify()`. """ row = self.objectify(form) session = self.Session() session.add(row) session.flush() return row
[docs] def redirect_after_create_row(self, row): """ Returns a redirect to the "view parent" page relative to the given newly-created row. Subclass may override as needed. This is called by :meth:`create_row()`. """ parent = self.get_row_parent(row) return self.redirect(self.get_action_url("view", parent))
[docs] def make_row_model_form(self, model_instance=None, **kwargs): """ Create and return a form for the row model. This is called by :meth:`create_row()`. See also related methods, which are called by this one: * :meth:`get_row_model_class()` * :meth:`get_row_form_fields()` * :meth:`~wuttaweb.views.base.View.make_form()` * :meth:`configure_row_form()` :returns: :class:`~wuttaweb.forms.base.Form` instance """ if "model_class" not in kwargs: model_class = self.get_row_model_class() if model_class: kwargs["model_class"] = model_class kwargs["model_instance"] = model_instance if not kwargs.get("fields"): fields = self.get_row_form_fields() if fields: kwargs["fields"] = fields form = self.make_form(**kwargs) self.configure_row_form(form) return form
[docs] def get_row_form_fields(self): """ Returns the initial list of field names for the row model form. This is called by :meth:`make_row_model_form()`; in the resulting :class:`~wuttaweb.forms.base.Form` instance, this becomes :attr:`~wuttaweb.forms.base.Form.fields`. This method may return ``None``, in which case the form may (try to) generate its own default list. Subclass may define :attr:`row_form_fields` for simple cases, or can override this method if needed. Note that :meth:`configure_row_form()` may be used to further modify the final field list, regardless of what this method returns. So a common pattern is to declare all "supported" fields by setting :attr:`row_form_fields` but then optionally remove or replace some in :meth:`configure_row_form()`. """ if hasattr(self, "row_form_fields"): return self.row_form_fields return None
[docs] def configure_row_form(self, form): """ Configure the row model form. This is called by :meth:`make_row_model_form()` - for multiple CRUD views (create, view, edit, delete, possibly others). The ``form`` param will already be "complete" and ready to use as-is, but this method can further modify it based on request details etc. Subclass can override as needed, although be sure to invoke this parent method via ``super()`` if so. """ form.remove("uuid") self.set_row_labels(form)
############################## # class methods ##############################
[docs] @classmethod def get_model_class(cls): """ Returns the model class for the view (if defined). A model class will *usually* be a SQLAlchemy mapped class, e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. There is no default value here, but a subclass may override by assigning :attr:`model_class`. Note that the model class is not *required* - however if you do not set the :attr:`model_class`, then you *must* set the :attr:`model_name`. """ return cls.model_class
[docs] @classmethod def get_model_name(cls): """ Returns the model name for the view. A model name should generally be in the format of a Python class name, e.g. ``'WuttaWidget'``. (Note this is *singular*, not plural.) The default logic will call :meth:`get_model_class()` and return that class name as-is. A subclass may override by assigning :attr:`model_name`. """ if hasattr(cls, "model_name"): return cls.model_name return cls.get_model_class().__name__
[docs] @classmethod def get_model_name_normalized(cls): """ Returns the "normalized" model name for the view. A normalized model name should generally be in the format of a Python variable name, e.g. ``'wutta_widget'``. (Note this is *singular*, not plural.) The default logic will call :meth:`get_model_name()` and simply lower-case the result. A subclass may override by assigning :attr:`model_name_normalized`. """ if hasattr(cls, "model_name_normalized"): return cls.model_name_normalized return cls.get_model_name().lower()
[docs] @classmethod def get_model_title(cls): """ Returns the "humanized" (singular) model title for the view. The model title will be displayed to the user, so should have proper grammar and capitalization, e.g. ``"Wutta Widget"``. (Note this is *singular*, not plural.) The default logic will call :meth:`get_model_name()` and use the result as-is. A subclass may override by assigning :attr:`model_title`. """ if hasattr(cls, "model_title"): return cls.model_title if model_class := cls.get_model_class(): if hasattr(model_class, "__wutta_hint__"): if model_title := model_class.__wutta_hint__.get("model_title"): return model_title return cls.get_model_name()
[docs] @classmethod def get_model_title_plural(cls): """ Returns the "humanized" (plural) model title for the view. The model title will be displayed to the user, so should have proper grammar and capitalization, e.g. ``"Wutta Widgets"``. (Note this is *plural*, not singular.) The default logic will call :meth:`get_model_title()` and simply add a ``'s'`` to the end. A subclass may override by assigning :attr:`model_title_plural`. """ if hasattr(cls, "model_title_plural"): return cls.model_title_plural if model_class := cls.get_model_class(): if hasattr(model_class, "__wutta_hint__"): if model_title_plural := model_class.__wutta_hint__.get( "model_title_plural" ): return model_title_plural model_title = cls.get_model_title() return f"{model_title}s"
[docs] @classmethod def get_model_key(cls): """ Returns the "model key" for the master view. This should return a tuple containing one or more "field names" corresponding to the primary key for data records. In the most simple/common scenario, where the master view represents a Wutta-based SQLAlchemy model, the return value for this method is: ``('uuid',)`` Any class mapped via SQLAlchemy should be supported automatically, the keys are determined from class inspection. But there is no "sane" default for other scenarios, in which case subclass should define :attr:`model_key`. If the model key cannot be determined, raises ``AttributeError``. :returns: Tuple of field names comprising the model key. """ if hasattr(cls, "model_key"): keys = cls.model_key if isinstance(keys, str): keys = [keys] return tuple(keys) model_class = cls.get_model_class() if model_class: # nb. we want the primary key but must avoid column names # in case mapped class uses different prop keys inspector = sa.inspect(model_class) keys = [col.name for col in inspector.primary_key] return tuple( prop.key for prop in inspector.column_attrs if all(col.name in keys for col in prop.columns) ) raise AttributeError(f"you must define model_key for view class: {cls}")
[docs] @classmethod def get_route_prefix(cls): """ Returns the "route prefix" for the master view. This prefix is used for all named routes defined by the view class. For instance if route prefix is ``'widgets'`` then a view might have these routes: * ``'widgets'`` * ``'widgets.create'`` * ``'widgets.edit'`` * ``'widgets.delete'`` The default logic will call :meth:`get_model_name_normalized()` and simply add an ``'s'`` to the end, making it plural. A subclass may override by assigning :attr:`route_prefix`. """ if hasattr(cls, "route_prefix"): return cls.route_prefix model_name = cls.get_model_name_normalized() return f"{model_name}s"
[docs] @classmethod def get_permission_prefix(cls): """ Returns the "permission prefix" for the master view. This prefix is used for all permissions defined by the view class. For instance if permission prefix is ``'widgets'`` then a view might have these permissions: * ``'widgets.list'`` * ``'widgets.create'`` * ``'widgets.edit'`` * ``'widgets.delete'`` The default logic will call :meth:`get_route_prefix()` and use that value as-is. A subclass may override by assigning :attr:`permission_prefix`. """ if hasattr(cls, "permission_prefix"): return cls.permission_prefix return cls.get_route_prefix()
[docs] @classmethod def get_url_prefix(cls): """ Returns the "URL prefix" for the master view. This prefix is used for all URLs defined by the view class. Using the same example as in :meth:`get_route_prefix()`, the URL prefix would be ``'/widgets'`` and the view would have defined routes for these URLs: * ``/widgets/`` * ``/widgets/new`` * ``/widgets/XXX/edit`` * ``/widgets/XXX/delete`` The default logic will call :meth:`get_route_prefix()` and simply add a ``'/'`` to the beginning. A subclass may override by assigning :attr:`url_prefix`. """ if hasattr(cls, "url_prefix"): return cls.url_prefix route_prefix = cls.get_route_prefix() return f"/{route_prefix}"
[docs] @classmethod def get_instance_url_prefix(cls): """ Generate the URL prefix specific to an instance for this model view. This will include model key param placeholders; it winds up looking like: * ``/widgets/{uuid}`` * ``/resources/{foo}|{bar}|{baz}`` The former being the most simple/common, and the latter showing what a "composite" model key looks like, with pipe symbols separating the key parts. """ prefix = cls.get_url_prefix() + "/" for i, key in enumerate(cls.get_model_key()): if i: prefix += "|" prefix += f"{{{key}}}" return prefix
[docs] @classmethod def get_template_prefix(cls): """ Returns the "template prefix" for the master view. This prefix is used to guess which template path to render for a given view. Using the same example as in :meth:`get_url_prefix()`, the template prefix would also be ``'/widgets'`` and the templates assumed for those routes would be: * ``/widgets/index.mako`` * ``/widgets/create.mako`` * ``/widgets/edit.mako`` * ``/widgets/delete.mako`` The default logic will call :meth:`get_url_prefix()` and return that value as-is. A subclass may override by assigning :attr:`template_prefix`. """ if hasattr(cls, "template_prefix"): return cls.template_prefix return cls.get_url_prefix()
[docs] @classmethod def get_grid_key(cls): """ Returns the (presumably) unique key to be used for the primary grid in the :meth:`index()` view. This key may also be used as the basis (key prefix) for secondary grids. This is called from :meth:`make_model_grid()`; in the resulting :class:`~wuttaweb.grids.base.Grid` instance, this becomes :attr:`~wuttaweb.grids.base.Grid.key`. The default logic for this method will call :meth:`get_route_prefix()` and return that value as-is. A subclass may override by assigning :attr:`grid_key`. """ if hasattr(cls, "grid_key"): return cls.grid_key return cls.get_route_prefix()
[docs] @classmethod def get_config_title(cls): """ Returns the "config title" for the view/model. The config title is used for page title in the :meth:`configure()` view, as well as links to it. It is usually plural, e.g. ``"Wutta Widgets"`` in which case that winds up being displayed in the web app as: **Configure Wutta Widgets** The default logic will call :meth:`get_model_title_plural()` and return that as-is. A subclass may override by assigning :attr:`config_title`. """ if hasattr(cls, "config_title"): return cls.config_title return cls.get_model_title_plural()
[docs] @classmethod def get_row_model_class(cls): """ Returns the "child row" model class for the view. Only relevant if :attr:`has_rows` is true. Default logic returns the :attr:`row_model_class` reference. :returns: Mapped class, or ``None`` """ return cls.row_model_class
[docs] @classmethod def get_row_model_name(cls): """ Returns the row model name for the view. A model name should generally be in the format of a Python class name, e.g. ``'BatchRow'``. (Note this is *singular*, not plural.) The default logic will call :meth:`get_row_model_class()` and return that class name as-is. Subclass may override by assigning :attr:`row_model_name`. """ if hasattr(cls, "row_model_name"): return cls.row_model_name return cls.get_row_model_class().__name__
[docs] @classmethod def get_row_model_title(cls): """ Returns the "humanized" (singular) title for the row model. The model title will be displayed to the user, so should have proper grammar and capitalization, e.g. ``"Batch Row"``. (Note this is *singular*, not plural.) The default logic will call :meth:`get_row_model_name()` and use the result as-is. Subclass may override by assigning :attr:`row_model_title`. See also :meth:`get_row_model_title_plural()`. """ if hasattr(cls, "row_model_title"): return cls.row_model_title if model_class := cls.get_row_model_class(): if hasattr(model_class, "__wutta_hint__"): if model_title := model_class.__wutta_hint__.get("model_title"): return model_title return cls.get_row_model_name()
[docs] @classmethod def get_row_model_title_plural(cls): """ Returns the "humanized" (plural) title for the row model. The model title will be displayed to the user, so should have proper grammar and capitalization, e.g. ``"Batch Rows"``. (Note this is *plural*, not singular.) The default logic will call :meth:`get_row_model_title()` and simply add a ``'s'`` to the end. Subclass may override by assigning :attr:`row_model_title_plural`. """ if hasattr(cls, "row_model_title_plural"): return cls.row_model_title_plural if model_class := cls.get_row_model_class(): if hasattr(model_class, "__wutta_hint__"): if model_title_plural := model_class.__wutta_hint__.get( "model_title_plural" ): return model_title_plural row_model_title = cls.get_row_model_title() return f"{row_model_title}s"
############################## # configuration ##############################
[docs] @classmethod def defaults(cls, config): """ Provide default Pyramid configuration for a master view. This is generally called from within the module's ``includeme()`` function, e.g.:: from wuttaweb.views import MasterView class WidgetView(MasterView): model_name = 'Widget' def includeme(config): WidgetView.defaults(config) :param config: Reference to the app's :class:`pyramid:pyramid.config.Configurator` instance. """ cls._defaults(config)
@classmethod def _defaults(cls, config): # pylint: disable=too-many-statements,too-many-branches wutta_config = config.registry.settings.get("wutta_config") app = wutta_config.get_app() route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() url_prefix = cls.get_url_prefix() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() # add to master view registry config.add_wutta_master_view(cls) # permission group config.add_wutta_permission_group( permission_prefix, model_title_plural, overwrite=False ) # index if cls.listable: config.add_route(route_prefix, f"{url_prefix}/") config.add_view( cls, attr="index", route_name=route_prefix, permission=f"{permission_prefix}.list", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.list", f"Browse / search {model_title_plural}", ) # grid totals if cls.has_grid_totals: config.add_route( f"{route_prefix}.fetch_grid_totals", f"{url_prefix}/fetch-grid-totals", request_method="GET", ) config.add_view( cls, attr="fetch_grid_totals", route_name=f"{route_prefix}.fetch_grid_totals", permission=f"{permission_prefix}.list", renderer="json", ) # create if cls.creatable: config.add_route(f"{route_prefix}.create", f"{url_prefix}/new") config.add_view( cls, attr="create", route_name=f"{route_prefix}.create", permission=f"{permission_prefix}.create", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.create", f"Create new {model_title}", ) # edit if cls.editable: instance_url_prefix = cls.get_instance_url_prefix() config.add_route(f"{route_prefix}.edit", f"{instance_url_prefix}/edit") config.add_view( cls, attr="edit", route_name=f"{route_prefix}.edit", permission=f"{permission_prefix}.edit", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.edit", f"Edit {model_title}" ) # delete if cls.deletable: instance_url_prefix = cls.get_instance_url_prefix() config.add_route(f"{route_prefix}.delete", f"{instance_url_prefix}/delete") config.add_view( cls, attr="delete", route_name=f"{route_prefix}.delete", permission=f"{permission_prefix}.delete", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.delete", f"Delete {model_title}", ) # bulk delete if cls.deletable_bulk: config.add_route( f"{route_prefix}.delete_bulk", f"{url_prefix}/delete-bulk", request_method="POST", ) config.add_view( cls, attr="delete_bulk", route_name=f"{route_prefix}.delete_bulk", permission=f"{permission_prefix}.delete_bulk", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.delete_bulk", f"Delete {model_title_plural} in bulk", ) # merge if cls.mergeable: config.add_wutta_permission( permission_prefix, f"{permission_prefix}.merge", f"Merge 2 {model_title_plural}", ) config.add_route(f"{route_prefix}.merge", f"{url_prefix}/merge") config.add_view( cls, attr="merge", route_name=f"{route_prefix}.merge", permission=f"{permission_prefix}.merge", ) # autocomplete if cls.has_autocomplete: config.add_route( f"{route_prefix}.autocomplete", f"{url_prefix}/autocomplete" ) config.add_view( cls, attr="autocomplete", route_name=f"{route_prefix}.autocomplete", renderer="json", permission=f"{route_prefix}.list", ) # download if cls.downloadable: instance_url_prefix = cls.get_instance_url_prefix() config.add_route( f"{route_prefix}.download", f"{instance_url_prefix}/download" ) config.add_view( cls, attr="download", route_name=f"{route_prefix}.download", permission=f"{permission_prefix}.download", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.download", f"Download file(s) for {model_title}", ) # execute if cls.executable: instance_url_prefix = cls.get_instance_url_prefix() config.add_route( f"{route_prefix}.execute", f"{instance_url_prefix}/execute", request_method="POST", ) config.add_view( cls, attr="execute", route_name=f"{route_prefix}.execute", permission=f"{permission_prefix}.execute", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.execute", f"Execute {model_title}", ) # configure if cls.configurable: config.add_route(f"{route_prefix}.configure", f"{url_prefix}/configure") config.add_view( cls, attr="configure", route_name=f"{route_prefix}.configure", permission=f"{permission_prefix}.configure", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.configure", f"Configure {model_title_plural}", ) # view # nb. always register this one last, so it does not take # priority over model-wide action routes, e.g. delete_bulk if cls.viewable: instance_url_prefix = cls.get_instance_url_prefix() config.add_route(f"{route_prefix}.view", instance_url_prefix) config.add_view( cls, attr="view", route_name=f"{route_prefix}.view", permission=f"{permission_prefix}.view", ) config.add_wutta_permission( permission_prefix, f"{permission_prefix}.view", f"View {model_title}" ) # version history if cls.is_versioned() and app.continuum_is_enabled(): instance_url_prefix = cls.get_instance_url_prefix() config.add_wutta_permission( permission_prefix, f"{permission_prefix}.versions", f"View version history for {model_title}", ) config.add_route( f"{route_prefix}.versions", f"{instance_url_prefix}/versions/" ) config.add_view( cls, attr="view_versions", route_name=f"{route_prefix}.versions", permission=f"{permission_prefix}.versions", ) config.add_route( f"{route_prefix}.version", f"{instance_url_prefix}/versions/{{txnid}}" ) config.add_view( cls, attr="view_version", route_name=f"{route_prefix}.version", permission=f"{permission_prefix}.versions", ) ############################## # row-specific routes ############################## # create row if cls.has_rows and cls.rows_creatable: config.add_wutta_permission( permission_prefix, f"{permission_prefix}.create_row", f'Create new "rows" for {model_title}', ) config.add_route( f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row" ) config.add_view( cls, attr="create_row", route_name=f"{route_prefix}.create_row", permission=f"{permission_prefix}.create_row", )