3
0
Fork 0

feat: add basic configure view for appinfo

This commit is contained in:
Lance Edgar 2024-08-06 18:52:54 -05:00
parent dd207a4a05
commit ed67cdb2d8
15 changed files with 847 additions and 42 deletions

View file

@ -110,7 +110,11 @@ def make_pyramid_config(settings):
The config is initialized with certain features deemed useful for
all apps.
:returns: Instance of
:class:`pyramid:pyramid.config.Configurator`.
"""
settings.setdefault('mako.directories', ['wuttaweb:templates'])
settings.setdefault('pyramid_deform.template_search_path',
'wuttaweb:templates/deform')
@ -119,6 +123,11 @@ def make_pyramid_config(settings):
# configure user authorization / authentication
pyramid_config.set_security_policy(WuttaSecurityPolicy())
# require CSRF token for POST
pyramid_config.set_default_csrf_options(require_csrf=True,
token='_csrf',
header='X-CSRF-TOKEN')
pyramid_config.include('pyramid_beaker')
pyramid_config.include('pyramid_deform')
pyramid_config.include('pyramid_mako')
@ -143,8 +152,6 @@ def main(global_config, **settings):
will need to define their own ``main()`` function, and use that
instead.
"""
settings.setdefault('mako.directories', ['wuttaweb:templates'])
wutta_config = make_wutta_config(settings)
pyramid_config = make_pyramid_config(settings)

View file

@ -323,6 +323,7 @@ class Form:
"""
context['form'] = self
context.setdefault('form_attrs', {})
context.setdefault('request', self.request)
# auto disable button on submit
if self.auto_disable_submit:

View file

@ -38,12 +38,20 @@ instance:
This module contains the following references:
* :func:`~wuttaweb.util.get_liburl()`
* all names from :mod:`webhelpers2:webhelpers2.html`
* all names from :mod:`webhelpers2:webhelpers2.html.tags`
* :func:`~wuttaweb.util.get_liburl()`
* :func:`~wuttaweb.util.get_csrf_token()`
* :func:`~wuttaweb.util.render_csrf_token()` (as :func:`csrf_token()`)
.. function:: csrf_token
This is a shorthand reference to
:func:`wuttaweb.util.render_csrf_token()`.
"""
from webhelpers2.html import *
from webhelpers2.html.tags import *
from wuttaweb.util import get_liburl
from wuttaweb.util import get_liburl, get_csrf_token, render_csrf_token as csrf_token

View file

@ -0,0 +1,21 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="App Title">
<b-input name="${app.appname}.app_title"
v-model="simpleSettings['${app.appname}.app_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</div>
</%def>
${parent.body()}

View file

@ -209,16 +209,14 @@
</div>
</nav>
<nav class="level" style="margin: 0.5rem auto;">
<nav class="level" style="margin: 0.5rem 0.5rem 0.5rem auto;">
<div class="level-left">
## Current Context
<div id="current-context" class="level-item">
% if index_title:
% if index_url:
<span class="header-text">
${h.link_to(index_title, index_url)}
</span>
<h1 class="title">${h.link_to(index_title, index_url)}</h1>
% else:
<h1 class="title">${index_title}</h1>
% endif
@ -226,6 +224,23 @@
</div>
</div><!-- level-left -->
<div class="level-right">
## TODO
% if master and master.configurable and not master.configuring:
<div class="level-item">
<b-button type="is-primary"
tag="a"
href="${url(f'{route_prefix}.configure')}"
icon-pack="fas"
icon-left="cog">
Configure
</b-button>
</div>
% endif
</div> <!-- level-right -->
</nav><!-- level -->
</header>
@ -318,8 +333,7 @@
<div class="navbar-dropdown">
% if request.is_root:
${h.form(url('stop_root'), ref='stopBeingRootForm')}
## TODO
## ${h.csrf_token(request)}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="stopBeingRoot()"
class="navbar-item has-background-danger has-text-white">
@ -328,8 +342,7 @@
${h.end_form()}
% elif request.is_admin:
${h.form(url('become_root'), ref='startBeingRootForm')}
## TODO
## ${h.csrf_token(request)}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="startBeingRoot()"
class="navbar-item has-background-danger has-text-white">

View file

@ -0,0 +1,181 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="title()">Configure ${config_title}</%def>
<%def name="page_content()">
<br />
${self.buttons_content()}
${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})}
${h.csrf_token(request)}
${self.form_content()}
${h.end_form()}
<b-modal has-modal-card
:active.sync="purgeSettingsShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Remove All Settings</p>
</header>
<section class="modal-card-body">
<p class="block">
Really remove all settings for ${config_title} from the DB?
</p>
<p class="block">
Note that when you <span class="is-italic">save</span>
settings, any existing settings are first removed and then
new ones are saved.
</p>
<p class="block">
But here you can remove existing without saving new
ones.&nbsp; It is basically "factory reset" for
${config_title}.
</p>
</section>
<footer class="modal-card-foot">
<b-button @click="purgeSettingsShowDialog = false">
Cancel
</b-button>
${h.form(request.current_route_url())}
${h.csrf_token(request)}
${h.hidden('remove_settings', 'true')}
<b-button type="is-danger"
native-type="submit"
:disabled="purgingSettings"
icon-pack="fas"
icon-left="trash"
@click="purgingSettings = true">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings for ${config_title}" }}
</b-button>
${h.end_form()}
</footer>
</div>
</b-modal>
</%def>
<%def name="buttons_content()">
<div class="level">
<div class="level-left">
<div class="level-item">
${self.intro_message()}
</div>
<div class="level-item">
${self.save_undo_buttons()}
</div>
</div>
<div class="level-right">
<div class="level-item">
${self.purge_button()}
</div>
</div>
</div>
</%def>
<%def name="intro_message()">
<p class="block">
This page lets you modify the settings for ${config_title}.
</p>
</%def>
<%def name="save_undo_buttons()">
<div class="buttons"
v-if="settingsNeedSaved">
<b-button type="is-primary"
@click="saveSettings"
:disabled="savingSettings"
icon-pack="fas"
icon-left="save">
{{ savingSettings ? "Working, please wait..." : "Save All Settings" }}
</b-button>
<b-button tag="a" href="${request.current_route_url()}"
icon-pack="fas"
icon-left="undo"
@click="undoChanges = true"
:disabled="undoChanges">
{{ undoChanges ? "Working, please wait..." : "Undo All Changes" }}
</b-button>
</div>
</%def>
<%def name="purge_button()">
<b-button type="is-danger"
@click="purgeSettingsShowDialog = true"
icon-pack="fas"
icon-left="trash">
Remove All Settings
</b-button>
</%def>
<%def name="form_content()">
<b-notification type="is-warning"
:closable="false">
<h4 class="block is-size-4">
TODO: you must define the
<span class="is-family-monospace">&lt;%def name="form_content()"&gt;</span>
template block
</h4>
<p class="block">
or if you need more control, define the
<span class="is-family-monospace">&lt;%def name="page_content()"&gt;</span>
template block
</p>
<p class="block">
for a real-world example see template at
<span class="is-family-monospace">wuttaweb:templates/appinfo/configure.mako</span>
</p>
</b-notification>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script>
% if simple_settings is not Undefined:
ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
% endif
ThisPageData.purgeSettingsShowDialog = false
ThisPageData.purgingSettings = false
ThisPageData.settingsNeedSaved = false
ThisPageData.undoChanges = false
ThisPageData.savingSettings = false
ThisPage.methods.saveSettings = function() {
this.savingSettings = true
this.$refs.saveSettingsForm.submit()
}
// nb. this is here to avoid auto-submitting form when user
// presses ENTER while some random input field has focus
ThisPage.methods.saveSettingsFormSubmit = function(event) {
if (!this.savingSettings) {
event.preventDefault()
}
}
// cf. https://stackoverflow.com/a/56551646
ThisPage.methods.beforeWindowUnload = function(e) {
if (this.settingsNeedSaved && !this.savingSettings && !this.undoChanges) {
e.preventDefault()
e.returnValue = ''
}
}
ThisPage.created = function() {
window.addEventListener('beforeunload', this.beforeWindowUnload)
}
</script>
</%def>
${parent.body()}

View file

@ -2,6 +2,7 @@
<script type="text/x-template" id="${form.vue_tagname}-template">
${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
${h.csrf_token(request)}
<section>
% for fieldname in form:

View file

@ -0,0 +1,9 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
## NB. /master/configure.mako is only a placeholder.
## there is no reason to *inherit* from this template;
## you can always just inherit from /configure.mako
${parent.body()}

View file

@ -26,6 +26,8 @@ Web Utilities
import importlib
from webhelpers2.html import HTML, tags
def get_form_data(request):
"""
@ -257,3 +259,44 @@ def get_liburl(
elif key == 'bb_vue_fontawesome':
return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
def get_csrf_token(request):
"""
Convenience function, returns the effective CSRF token (raw
string) for the given request.
See also :func:`render_csrf_token()`.
"""
token = request.session.get_csrf_token()
if token is None:
token = request.session.new_csrf_token()
return token
def render_csrf_token(request, name='_csrf'):
"""
Convenience function, returns CSRF hidden input inside hidden div,
e.g.:
.. code-block:: html
<div style="display: none;">
<input type="hidden" name="_csrf" value="TOKEN" />
</div>
This function is part of :mod:`wuttaweb.helpers` (as
:func:`~wuttaweb.helpers.csrf_token()`) which means you can do
this in page templates:
.. code-block:: mako
${h.form(request.current_route_url())}
${h.csrf_token(request)}
<!-- other fields etc. -->
${h.end_form()}
See also :func:`get_csrf_token()`.
"""
token = get_csrf_token(request)
return HTML.tag('div', tags.hidden(name, value=token), style='display:none;')

View file

@ -27,6 +27,8 @@ Base Logic for Master Views
from pyramid.renderers import render_to_response
from wuttaweb.views import View
from wuttaweb.util import get_form_data
from wuttaweb.db import Session
class MasterView(View):
@ -98,6 +100,14 @@ class MasterView(View):
Code should not access this directly but instead call
:meth:`get_model_title_plural()`.
.. 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,
@ -125,17 +135,29 @@ class MasterView(View):
.. attribute:: listable
Boolean indicating whether the view model supports "listing" -
i.e. it should have an :meth:`index()` view.
i.e. it should have an :meth:`index()` view. Default value is
``True``.
.. attribute:: configurable
Boolean indicating whether the master view supports
"configuring" - i.e. it should have a :meth:`configure()` view.
Default value is ``False``.
"""
##############################
# attributes
##############################
# features
listable = True
configurable = False
# current action
configuring = False
##############################
# view methods
# index methods
##############################
def index(self):
@ -145,8 +167,304 @@ class MasterView(View):
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.
"""
return self.render_to_response('index', {})
context = {
'index_url': None, # avoid title link since this *is* the index
}
return self.render_to_response('index', context)
##############################
# configure methods
##############################
def configure(self):
"""
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()
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()
self.configure_save_settings(settings)
self.request.session.flash("Settings have been saved.")
# reload configure page
return self.redirect(self.request.current_route_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)
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.
"""
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]
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,
):
"""
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()
for name in names:
self.app.delete_setting(session, name)
def configure_save_settings(self, settings):
"""
Save the given settings to the DB; this is called by
:meth:`configure()`.
This method expected 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()`.
"""
# app = self.get_rattail_app()
# nb. must avoid self.Session here in case that does not point
# to our primary app DB
session = Session()
for setting in settings:
self.app.save_setting(session, setting['name'], setting['value'],
force_create=True)
##############################
# support methods
@ -162,6 +480,16 @@ class MasterView(View):
"""
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)
def render_to_response(self, template, context):
"""
Locate and render an appropriate template, with the given
@ -192,7 +520,11 @@ class MasterView(View):
: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(),
'config_title': self.get_config_title(),
}
# merge defaults + caller-provided context
@ -406,6 +738,26 @@ class MasterView(View):
return cls.get_url_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()
##############################
# configuration
##############################
@ -436,8 +788,15 @@ class MasterView(View):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
# index view
# index
if cls.listable:
config.add_route(route_prefix, f'{url_prefix}/')
config.add_view(cls, attr='index',
route_name=route_prefix)
# 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')

View file

@ -29,11 +29,26 @@ from wuttaweb.views import MasterView
class AppInfoView(MasterView):
"""
Master view for the overall app, to show/edit config etc.
Master view for the core app info, to show/edit config etc.
Notable URLs provided by this class:
* ``/appinfo/``
* ``/appinfo/configure``
"""
model_name = 'AppInfo'
model_title_plural = "App Info"
route_prefix = 'appinfo'
configurable = True
def configure_get_simple_settings(self):
""" """
return [
# basics
{'name': f'{self.app.appname}.app_title'},
]
def defaults(config, **kwargs):