3
0
Fork 0
wuttaweb/src/wuttaweb/views/master.py
Lance Edgar ee3a789682 feat: add basic support for merging 2 records, w/ preview
including basic logic for merging Person or User records
2026-03-20 17:20:02 -05:00

4519 lines
152 KiB
Python

# -*- 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__)
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:: 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
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
##############################
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)
##############################
# create methods
##############################
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)
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())
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
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
##############################
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)
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
##############################
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)
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)
)
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
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
##############################
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)
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
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)
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())
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)
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,
)
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
##############################
@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
@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())
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
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,
},
)
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
@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"
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",
]
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())
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 []
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
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", "")
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,
},
)
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
##############################
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
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
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
##############################
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)
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
##############################
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,
)
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
##############################
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)
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
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 []
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
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)
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
##############################
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"
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")
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
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
##############################
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)
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}")
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
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
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 []
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)
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)
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)
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
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
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"]
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()
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
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)
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
def make_model_grid(
self, session=None, **kwargs
): # pylint: disable=too-many-branches
"""
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 = []
# 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
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
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 []
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
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)
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__}"
)
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)"
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()}
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)
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)
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
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
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
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
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
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
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)
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
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)
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
##############################
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),
)
context = {"removing": removing, "keeping": keeping, "diff": diff}
return self.render_to_response("merge", context)
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
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 []
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 []
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)
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()}
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
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
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
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
##############################
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()
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
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
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)
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
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
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)
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)
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
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
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)
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
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))
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
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
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
##############################
@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
@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__
@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()
@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()
@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"
@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}")
@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"
@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()
@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}"
@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
@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()
@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()
@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()
@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
@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__
@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()
@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
##############################
@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}",
)
# 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",
)