feat: add basic configure view for appinfo
This commit is contained in:
parent
dd207a4a05
commit
ed67cdb2d8
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
21
src/wuttaweb/templates/appinfo/configure.mako
Normal file
21
src/wuttaweb/templates/appinfo/configure.mako
Normal 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()}
|
|
@ -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">
|
||||
|
|
181
src/wuttaweb/templates/configure.mako
Normal file
181
src/wuttaweb/templates/configure.mako
Normal 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. 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"><%def name="form_content()"></span>
|
||||
template block
|
||||
</h4>
|
||||
<p class="block">
|
||||
or if you need more control, define the
|
||||
<span class="is-family-monospace"><%def name="page_content()"></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()}
|
|
@ -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:
|
||||
|
|
9
src/wuttaweb/templates/master/configure.mako
Normal file
9
src/wuttaweb/templates/master/configure.mako
Normal 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()}
|
|
@ -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;')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -8,6 +8,17 @@ from pyramid.config import Configurator
|
|||
from pyramid.router import Router
|
||||
|
||||
from wuttaweb import app as mod
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
|
||||
|
||||
class TestWebAppProvider(TestCase):
|
||||
|
||||
def test_basic(self):
|
||||
# nb. just normal usage here, confirm it does the one thing we
|
||||
# need it to..
|
||||
config = WuttaConfig()
|
||||
app = config.get_app()
|
||||
handler = app.get_web_handler()
|
||||
|
||||
|
||||
class TestMakeWuttaConfig(FileConfigTestCase):
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyramid import testing
|
||||
|
||||
|
@ -290,3 +291,45 @@ class TestGetFormData(TestCase):
|
|||
request = self.make_request(POST=None, content_type='application/json')
|
||||
data = util.get_form_data(request)
|
||||
self.assertEqual(data, {'foo2': 'baz'})
|
||||
|
||||
|
||||
class TestGetCsrfToken(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig()
|
||||
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||
|
||||
def test_same_token(self):
|
||||
|
||||
# same token returned for same request
|
||||
# TODO: dummy request is always returning same token!
|
||||
# so this isn't really testing anything.. :(
|
||||
first = util.get_csrf_token(self.request)
|
||||
self.assertIsNotNone(first)
|
||||
second = util.get_csrf_token(self.request)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
# TODO: ideally would make a new request here and confirm it
|
||||
# gets a different token, but see note above..
|
||||
|
||||
def test_new_token(self):
|
||||
|
||||
# nb. dummy request always returns same token, so must
|
||||
# trick it into thinking it doesn't have one yet
|
||||
with patch.object(self.request.session, 'get_csrf_token', return_value=None):
|
||||
token = util.get_csrf_token(self.request)
|
||||
self.assertIsNotNone(token)
|
||||
|
||||
|
||||
class TestRenderCsrfToken(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig()
|
||||
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||
|
||||
def test_basics(self):
|
||||
html = util.render_csrf_token(self.request)
|
||||
self.assertIn('type="hidden"', html)
|
||||
self.assertIn('name="_csrf"', html)
|
||||
token = util.get_csrf_token(self.request)
|
||||
self.assertIn(f'value="{token}"', html)
|
||||
|
|
|
@ -1,39 +1,21 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import functools
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pyramid import testing
|
||||
from pyramid.response import Response
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from wuttjamaican.conf import WuttaConfig
|
||||
from wuttaweb.views import master
|
||||
from wuttaweb.subscribers import new_request_set_user
|
||||
|
||||
from tests.views.utils import WebTestCase
|
||||
|
||||
class TestMasterView(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.config = WuttaConfig(defaults={
|
||||
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
|
||||
})
|
||||
self.app = self.config.get_app()
|
||||
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||
self.pyramid_config = testing.setUp(request=self.request, settings={
|
||||
'wutta_config': self.config,
|
||||
'mako.directories': ['wuttaweb:templates'],
|
||||
})
|
||||
self.pyramid_config.include('pyramid_mako')
|
||||
self.pyramid_config.include('wuttaweb.static')
|
||||
self.pyramid_config.include('wuttaweb.views.essential')
|
||||
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
|
||||
'pyramid.events.BeforeRender')
|
||||
|
||||
event = MagicMock(request=self.request)
|
||||
new_request_set_user(event)
|
||||
|
||||
def tearDown(self):
|
||||
testing.tearDown()
|
||||
class TestMasterView(WebTestCase):
|
||||
|
||||
def test_defaults(self):
|
||||
master.MasterView.model_name = 'Widget'
|
||||
|
@ -233,6 +215,37 @@ class TestMasterView(TestCase):
|
|||
self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
|
||||
del master.MasterView.model_class
|
||||
|
||||
def test_get_config_title(self):
|
||||
|
||||
# error by default (since no model class)
|
||||
self.assertRaises(AttributeError, master.MasterView.get_config_title)
|
||||
|
||||
# subclass may specify config title
|
||||
master.MasterView.config_title = 'Widgets'
|
||||
self.assertEqual(master.MasterView.get_config_title(), "Widgets")
|
||||
del master.MasterView.config_title
|
||||
|
||||
# subclass may specify *plural* model title
|
||||
master.MasterView.model_title_plural = 'People'
|
||||
self.assertEqual(master.MasterView.get_config_title(), "People")
|
||||
del master.MasterView.model_title_plural
|
||||
|
||||
# or it may specify *singular* model title
|
||||
master.MasterView.model_title = 'Wutta Widget'
|
||||
self.assertEqual(master.MasterView.get_config_title(), "Wutta Widgets")
|
||||
del master.MasterView.model_title
|
||||
|
||||
# or it may specify model name
|
||||
master.MasterView.model_name = 'Blaster'
|
||||
self.assertEqual(master.MasterView.get_config_title(), "Blasters")
|
||||
del master.MasterView.model_name
|
||||
|
||||
# or it may specify model class
|
||||
MyModel = MagicMock(__name__='Dinosaur')
|
||||
master.MasterView.model_class = MyModel
|
||||
self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs")
|
||||
del master.MasterView.model_class
|
||||
|
||||
##############################
|
||||
# support methods
|
||||
##############################
|
||||
|
@ -245,6 +258,10 @@ class TestMasterView(TestCase):
|
|||
|
||||
def test_render_to_response(self):
|
||||
|
||||
def widgets(request): return {}
|
||||
self.pyramid_config.add_route('widgets', '/widgets/')
|
||||
self.pyramid_config.add_view(widgets, route_name='widgets')
|
||||
|
||||
# basic sanity check using /master/index.mako
|
||||
# (nb. it skips /widgets/index.mako since that doesn't exist)
|
||||
master.MasterView.model_name = 'Widget'
|
||||
|
@ -255,12 +272,14 @@ class TestMasterView(TestCase):
|
|||
|
||||
# basic sanity check using /appinfo/index.mako
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.template_prefix = '/appinfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.url_prefix = '/appinfo'
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.template_prefix
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.url_prefix
|
||||
|
||||
# bad template name causes error
|
||||
master.MasterView.model_name = 'Widget'
|
||||
|
@ -275,8 +294,77 @@ class TestMasterView(TestCase):
|
|||
|
||||
# basic sanity check using /appinfo
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.template_prefix = '/appinfo'
|
||||
view = master.MasterView(self.request)
|
||||
response = view.index()
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.template_prefix
|
||||
|
||||
def test_configure(self):
|
||||
model = self.app.model
|
||||
|
||||
# setup
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.template_prefix = '/appinfo'
|
||||
|
||||
# mock settings
|
||||
settings = [
|
||||
{'name': 'wutta.app_title'},
|
||||
{'name': 'wutta.foo', 'value': 'bar'},
|
||||
{'name': 'wutta.flag', 'type': bool},
|
||||
{'name': 'wutta.number', 'type': int, 'default': 42},
|
||||
{'name': 'wutta.value1', 'save_if_empty': True},
|
||||
{'name': 'wutta.value2', 'save_if_empty': False},
|
||||
]
|
||||
|
||||
view = master.MasterView(self.request)
|
||||
with patch.object(self.request, 'current_route_url',
|
||||
return_value='/appinfo/configure'):
|
||||
with patch.object(master.MasterView, 'configure_get_simple_settings',
|
||||
return_value=settings):
|
||||
with patch.object(master, 'Session', return_value=self.session):
|
||||
|
||||
# get the form page
|
||||
response = view.configure()
|
||||
self.assertIsInstance(response, Response)
|
||||
|
||||
# post request to save settings
|
||||
self.request.method = 'POST'
|
||||
self.request.POST = {
|
||||
'wutta.app_title': 'Wutta',
|
||||
'wutta.foo': 'bar',
|
||||
'wutta.flag': 'true',
|
||||
}
|
||||
response = view.configure()
|
||||
# nb. should get redirect back to configure page
|
||||
self.assertIsInstance(response, HTTPFound)
|
||||
|
||||
# should now have 5 settings
|
||||
count = self.session.query(model.Setting).count()
|
||||
self.assertEqual(count, 5)
|
||||
get_setting = functools.partial(self.app.get_setting, self.session)
|
||||
self.assertEqual(get_setting('wutta.app_title'), 'Wutta')
|
||||
self.assertEqual(get_setting('wutta.foo'), 'bar')
|
||||
self.assertEqual(get_setting('wutta.flag'), 'true')
|
||||
self.assertEqual(get_setting('wutta.number'), '42')
|
||||
self.assertEqual(get_setting('wutta.value1'), '')
|
||||
self.assertEqual(get_setting('wutta.value2'), None)
|
||||
|
||||
# post request to remove settings
|
||||
self.request.method = 'POST'
|
||||
self.request.POST = {'remove_settings': '1'}
|
||||
response = view.configure()
|
||||
# nb. should get redirect back to configure page
|
||||
self.assertIsInstance(response, HTTPFound)
|
||||
|
||||
# should now have 0 settings
|
||||
count = self.session.query(model.Setting).count()
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
# teardown
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.template_prefix
|
||||
|
|
|
@ -11,3 +11,8 @@ class TestAppInfoView(WebTestCase):
|
|||
# just a sanity check
|
||||
view = settings.AppInfoView(self.request)
|
||||
response = view.index()
|
||||
|
||||
def test_configure_get_simple_settings(self):
|
||||
# just a sanity check
|
||||
view = settings.AppInfoView(self.request)
|
||||
simple = view.configure_get_simple_settings()
|
||||
|
|
Loading…
Reference in a new issue