From ed67cdb2d87251df4ab415d834f2f05f2a3d1984 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 6 Aug 2024 18:52:54 -0500
Subject: [PATCH 1/3] feat: add basic configure view for appinfo

---
 src/wuttaweb/app.py                           |  11 +-
 src/wuttaweb/forms/base.py                    |   1 +
 src/wuttaweb/helpers.py                       |  12 +-
 src/wuttaweb/templates/appinfo/configure.mako |  21 +
 src/wuttaweb/templates/base.mako              |  29 +-
 src/wuttaweb/templates/configure.mako         | 181 +++++++++
 .../templates/forms/vue_template.mako         |   1 +
 src/wuttaweb/templates/master/configure.mako  |   9 +
 src/wuttaweb/util.py                          |  43 ++
 src/wuttaweb/views/master.py                  | 367 +++++++++++++++++-
 src/wuttaweb/views/settings.py                |  17 +-
 tests/test_app.py                             |  11 +
 tests/test_util.py                            |  43 ++
 tests/views/test_master.py                    | 138 +++++--
 tests/views/test_settings.py                  |   5 +
 15 files changed, 847 insertions(+), 42 deletions(-)
 create mode 100644 src/wuttaweb/templates/appinfo/configure.mako
 create mode 100644 src/wuttaweb/templates/configure.mako
 create mode 100644 src/wuttaweb/templates/master/configure.mako

diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index 6aadc0c..8b4a610 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -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)
 
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
index 0974a50..42abb31 100644
--- a/src/wuttaweb/forms/base.py
+++ b/src/wuttaweb/forms/base.py
@@ -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:
diff --git a/src/wuttaweb/helpers.py b/src/wuttaweb/helpers.py
index c80c62f..80b9d21 100644
--- a/src/wuttaweb/helpers.py
+++ b/src/wuttaweb/helpers.py
@@ -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
diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako
new file mode 100644
index 0000000..218d092
--- /dev/null
+++ b/src/wuttaweb/templates/appinfo/configure.mako
@@ -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()}
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index b04c980..6b5dfd9 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -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">
diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako
new file mode 100644
index 0000000..58b707e
--- /dev/null
+++ b/src/wuttaweb/templates/configure.mako
@@ -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()}
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index 11767fd..bee0d55 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -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:
diff --git a/src/wuttaweb/templates/master/configure.mako b/src/wuttaweb/templates/master/configure.mako
new file mode 100644
index 0000000..59db4ad
--- /dev/null
+++ b/src/wuttaweb/templates/master/configure.mako
@@ -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()}
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index a8d059f..85cfd70 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -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;')
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 9ba1572..2cf719a 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -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')
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index 42ce834..f07524c 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -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):
diff --git a/tests/test_app.py b/tests/test_app.py
index 2d5307e..5ce8e94 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -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):
diff --git a/tests/test_util.py b/tests/test_util.py
index c68d42c..742ef91 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -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)
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 8fe4c47..3380a8e 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -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
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
index 321364b..0968052 100644
--- a/tests/views/test_settings.py
+++ b/tests/views/test_settings.py
@@ -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()

From d35e6e71c96443281f90ebcc6e06f3052345d4d3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 6 Aug 2024 19:59:30 -0500
Subject: [PATCH 2/3] feat: expose Web Libraries in app info config page

get away from that CDN, hopefully speeds things up etc.
---
 src/wuttaweb/templates/appinfo/configure.mako | 156 ++++++++++++++++++
 src/wuttaweb/util.py                          |  68 +++++---
 src/wuttaweb/views/settings.py                |  76 +++++++++
 tests/test_util.py                            |  21 +++
 tests/views/test_settings.py                  |   9 +-
 5 files changed, 306 insertions(+), 24 deletions(-)

diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako
index 218d092..1ea6481 100644
--- a/src/wuttaweb/templates/appinfo/configure.mako
+++ b/src/wuttaweb/templates/appinfo/configure.mako
@@ -13,8 +13,164 @@
       </b-input>
     </b-field>
 
+    <b-field>
+      <b-checkbox name="${app.appname}.production"
+                  v-model="simpleSettings['${app.appname}.production']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Production Mode
+      </b-checkbox>
+    </b-field>
+
   </div>
 
+  <h3 class="block is-size-3">Web Libraries</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <${b}-table :data="weblibs">
+
+      <${b}-table-column field="title"
+                      label="Name"
+                      v-slot="props">
+        {{ props.row.title }}
+      </${b}-table-column>
+
+      <${b}-table-column field="configured_version"
+                      label="Version"
+                      v-slot="props">
+        {{ props.row.configured_version || props.row.default_version }}
+      </${b}-table-column>
+
+      <${b}-table-column field="configured_url"
+                      label="URL Override"
+                      v-slot="props">
+        {{ props.row.configured_url }}
+      </${b}-table-column>
+
+      <${b}-table-column field="live_url"
+                      label="Effective (Live) URL"
+                      v-slot="props">
+        <span v-if="props.row.modified"
+              class="has-text-warning">
+          save settings and refresh page to see new URL
+        </span>
+        <span v-if="!props.row.modified">
+          {{ props.row.live_url }}
+        </span>
+      </${b}-table-column>
+
+      <${b}-table-column field="actions"
+                      label="Actions"
+                      v-slot="props">
+        <a href="#"
+           @click.prevent="editWebLibraryInit(props.row)">
+          % if request.use_oruga:
+              <o-icon icon="edit" />
+          % else:
+              <i class="fas fa-edit"></i>
+          % endif
+          Edit
+        </a>
+      </${b}-table-column>
+
+    </${b}-table>
+
+    % for weblib in weblibs or []:
+        ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})}
+        ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})}
+    % endfor
+
+    <${b}-modal has-modal-card
+                % if request.use_oruga:
+                    v-model:active="editWebLibraryShowDialog"
+                % else:
+                    :active.sync="editWebLibraryShowDialog"
+                % endif
+                >
+      <div class="modal-card">
+
+        <header class="modal-card-head">
+          <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p>
+        </header>
+
+        <section class="modal-card-body">
+
+          <b-field grouped>
+
+            <b-field label="Default Version">
+              <b-input v-model="editWebLibraryRecord.default_version"
+                       disabled>
+              </b-input>
+            </b-field>
+
+            <b-field label="Override Version">
+              <b-input v-model="editWebLibraryVersion">
+              </b-input>
+            </b-field>
+
+          </b-field>
+
+          <b-field label="Override URL">
+            <b-input v-model="editWebLibraryURL"
+                     expanded />
+          </b-field>
+
+          <b-field label="Effective URL (as of last page load)">
+            <b-input v-model="editWebLibraryRecord.live_url"
+                     disabled
+                     expanded />
+          </b-field>
+
+        </section>
+
+        <footer class="modal-card-foot">
+          <b-button type="is-primary"
+                    @click="editWebLibrarySave()"
+                    icon-pack="fas"
+                    icon-left="save">
+            Save
+          </b-button>
+          <b-button @click="editWebLibraryShowDialog = false">
+            Cancel
+          </b-button>
+        </footer>
+      </div>
+    </${b}-modal>
+
+  </div>
+</%def>
+
+<%def name="modify_this_page_vars()">
+  ${parent.modify_this_page_vars()}
+  <script>
+
+    ThisPageData.weblibs = ${json.dumps(weblibs or [])|n}
+
+    ThisPageData.editWebLibraryShowDialog = false
+    ThisPageData.editWebLibraryRecord = {}
+    ThisPageData.editWebLibraryVersion = null
+    ThisPageData.editWebLibraryURL = null
+
+    ThisPage.methods.editWebLibraryInit = function(row) {
+        this.editWebLibraryRecord = row
+        this.editWebLibraryVersion = row.configured_version
+        this.editWebLibraryURL = row.configured_url
+        this.editWebLibraryShowDialog = true
+    }
+
+    ThisPage.methods.editWebLibrarySave = function() {
+        this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion
+        this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
+        this.editWebLibraryRecord.modified = true
+
+        this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
+        this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
+
+        this.settingsNeedSaved = true
+        this.editWebLibraryShowDialog = false
+    }
+
+  </script>
 </%def>
 
 
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 85cfd70..61614ad 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -53,6 +53,7 @@ def get_form_data(request):
 def get_libver(
         request,
         key,
+        configured_only=False,
         default_only=False,
         prefix='wuttaweb',
 ):
@@ -78,13 +79,11 @@ def get_libver(
     :param key: Unique key for the library, as string.  Possibilities
        are the same as for :func:`get_liburl()`.
 
-    :param default_only: If this flag is ``True``, the logic will
-       *not* look for a "configured" version but rather will *only*
-       return the "default" version regardless of config.
+    :param configured_only: Pass ``True`` here if you only want the
+       configured version and ignore the default version.
 
-       If the flag is ``False`` (which it is by default) then the
-       config value will be used if present, and a default version is
-       used only if the config does not have a value.
+    :param default_only: Pass ``True`` here if you only want the
+       default version and ignore the configured version.
 
     :param prefix: If specified, will override the prefix used for
        config lookups.
@@ -95,7 +94,7 @@ def get_libver(
           be removed in the future.
 
     :returns: The appropriate version string, e.g. ``'1.2.3'`` or
-       ``'latest'`` etc.
+       ``'latest'`` etc.  Can also return ``None`` in some cases.
     """
     config = request.wutta_config
 
@@ -115,11 +114,14 @@ def get_libver(
             version = config.get(f'{prefix}.buefy_version')
             if version:
                 return version
-        return 'latest'
+        if not configured_only:
+            return 'latest'
 
     elif key == 'buefy.css':
         # nb. this always returns something
-        return get_libver(request, 'buefy', default_only=default_only)
+        return get_libver(request, 'buefy',
+                          default_only=default_only,
+                          configured_only=configured_only)
 
     elif key == 'vue':
         if not default_only:
@@ -127,36 +129,47 @@ def get_libver(
             version = config.get(f'{prefix}.vue_version')
             if version:
                 return version
-        return '2.6.14'
+        if not configured_only:
+            return '2.6.14'
 
     elif key == 'vue_resource':
-        return 'latest'
+        if not configured_only:
+            return 'latest'
 
     elif key == 'fontawesome':
-        return '5.3.1'
+        if not configured_only:
+            return '5.3.1'
 
     elif key == 'bb_vue':
-        return '3.4.31'
+        if not configured_only:
+            return '3.4.31'
 
     elif key == 'bb_oruga':
-        return '0.8.12'
+        if not configured_only:
+            return '0.8.12'
 
     elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'):
-        return '0.3.0'
+        if not configured_only:
+            return '0.3.0'
 
     elif key == 'bb_fontawesome_svg_core':
-        return '6.5.2'
+        if not configured_only:
+            return '6.5.2'
 
     elif key == 'bb_free_solid_svg_icons':
-        return '6.5.2'
+        if not configured_only:
+            return '6.5.2'
 
     elif key == 'bb_vue_fontawesome':
-        return '3.0.6'
+        if not configured_only:
+            return '3.0.6'
 
 
 def get_liburl(
         request,
         key,
+        configured_only=False,
+        default_only=False,
         prefix='wuttaweb',
 ):
     """
@@ -206,6 +219,12 @@ def get_liburl(
        * ``bb_free_solid_svg_icons``
        * ``bb_vue_fontawesome``
 
+    :param configured_only: Pass ``True`` here if you only want the
+       configured URL and ignore the default URL.
+
+    :param default_only: Pass ``True`` here if you only want the
+       default URL and ignore the configured URL.
+
     :param prefix: If specified, will override the prefix used for
        config lookups.
 
@@ -214,13 +233,18 @@ def get_liburl(
           This ``prefix`` param is for backward compatibility and may
           be removed in the future.
 
-    :returns: The appropriate URL as string.
+    :returns: The appropriate URL as string.  Can also return ``None``
+       in some cases.
     """
     config = request.wutta_config
 
-    url = config.get(f'{prefix}.liburl.{key}')
-    if url:
-        return url
+    if not default_only:
+        url = config.get(f'{prefix}.liburl.{key}')
+        if url:
+            return url
+
+    if configured_only:
+        return
 
     version = get_libver(request, key, prefix=prefix)
 
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index f07524c..bfe6830 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -24,7 +24,10 @@
 Views for app settings
 """
 
+from collections import OrderedDict
+
 from wuttaweb.views import MasterView
+from wuttaweb.util import get_libver, get_liburl
 
 
 class AppInfoView(MasterView):
@@ -47,9 +50,82 @@ class AppInfoView(MasterView):
 
             # basics
             {'name': f'{self.app.appname}.app_title'},
+            {'name': f'{self.app.appname}.production',
+             'type': bool},
+
+            # web libs
+            {'name': 'wuttaweb.libver.vue'},
+            {'name': 'wuttaweb.liburl.vue'},
+            {'name': 'wuttaweb.libver.vue_resource'},
+            {'name': 'wuttaweb.liburl.vue_resource'},
+            {'name': 'wuttaweb.libver.buefy'},
+            {'name': 'wuttaweb.liburl.buefy'},
+            {'name': 'wuttaweb.libver.buefy.css'},
+            {'name': 'wuttaweb.liburl.buefy.css'},
+            {'name': 'wuttaweb.libver.fontawesome'},
+            {'name': 'wuttaweb.liburl.fontawesome'},
+            {'name': 'wuttaweb.libver.bb_vue'},
+            {'name': 'wuttaweb.liburl.bb_vue'},
+            {'name': 'wuttaweb.libver.bb_oruga'},
+            {'name': 'wuttaweb.liburl.bb_oruga'},
+            {'name': 'wuttaweb.libver.bb_oruga_bulma'},
+            {'name': 'wuttaweb.liburl.bb_oruga_bulma'},
+            {'name': 'wuttaweb.libver.bb_oruga_bulma_css'},
+            {'name': 'wuttaweb.liburl.bb_oruga_bulma_css'},
+            {'name': 'wuttaweb.libver.bb_fontawesome_svg_core'},
+            {'name': 'wuttaweb.liburl.bb_fontawesome_svg_core'},
+            {'name': 'wuttaweb.libver.bb_free_solid_svg_icons'},
+            {'name': 'wuttaweb.liburl.bb_free_solid_svg_icons'},
+            {'name': 'wuttaweb.libver.bb_vue_fontawesome'},
+            {'name': 'wuttaweb.liburl.bb_vue_fontawesome'},
 
         ]
 
+    def configure_get_context(self, **kwargs):
+        """ """
+
+        # normal context
+        context = super().configure_get_context(**kwargs)
+
+        # we will add `weblibs` to context, based on config values
+        weblibs = OrderedDict([
+            ('vue', "Vue"),
+            ('vue_resource', "vue-resource"),
+            ('buefy', "Buefy"),
+            ('buefy.css', "Buefy CSS"),
+            ('fontawesome', "FontAwesome"),
+            ('bb_vue', "(BB) vue"),
+            ('bb_oruga', "(BB) @oruga-ui/oruga-next"),
+            ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"),
+            ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"),
+            ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"),
+            ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"),
+            ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
+        ])
+
+        # import ipdb; ipdb.set_trace()
+
+        for key in weblibs:
+            title = weblibs[key]
+            weblibs[key] = {
+                'key': key,
+                'title': title,
+
+                # nb. these values are exactly as configured, and are
+                # used for editing the settings
+                'configured_version': get_libver(self.request, key,
+                                                 configured_only=True),
+                'configured_url': get_liburl(self.request, key,
+                                             configured_only=True),
+
+                # nb. these are for display only
+                'default_version': get_libver(self.request, key, default_only=True),
+                'live_url': get_liburl(self.request, key),
+            }
+
+        context['weblibs'] = list(weblibs.values())
+        return context
+
 
 def defaults(config, **kwargs):
     base = globals()
diff --git a/tests/test_util.py b/tests/test_util.py
index 742ef91..44817e5 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -30,6 +30,10 @@ class TestGetLibVer(TestCase):
         version = util.get_libver(self.request, 'buefy')
         self.assertEqual(version, '0.9.29')
 
+    def test_buefy_configured_only(self):
+        version = util.get_libver(self.request, 'buefy', configured_only=True)
+        self.assertIsNone(version)
+
     def test_buefy_default_only(self):
         self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
         version = util.get_libver(self.request, 'buefy', default_only=True)
@@ -51,6 +55,10 @@ class TestGetLibVer(TestCase):
         version = util.get_libver(self.request, 'buefy.css')
         self.assertEqual(version, '0.9.29')
 
+    def test_buefy_css_configured_only(self):
+        version = util.get_libver(self.request, 'buefy.css', configured_only=True)
+        self.assertIsNone(version)
+
     def test_buefy_css_default_only(self):
         self.config.setdefault('wuttaweb.libver.buefy', '0.9.29')
         version = util.get_libver(self.request, 'buefy.css', default_only=True)
@@ -70,6 +78,10 @@ class TestGetLibVer(TestCase):
         version = util.get_libver(self.request, 'vue')
         self.assertEqual(version, '3.4.31')
 
+    def test_vue_configured_only(self):
+        version = util.get_libver(self.request, 'vue', configured_only=True)
+        self.assertIsNone(version)
+
     def test_vue_default_only(self):
         self.config.setdefault('wuttaweb.libver.vue', '3.4.31')
         version = util.get_libver(self.request, 'vue', default_only=True)
@@ -166,6 +178,15 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'buefy')
         self.assertEqual(url, '/lib/buefy.js')
 
+    def test_buefy_default_only(self):
+        self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js')
+        url = util.get_liburl(self.request, 'buefy', default_only=True)
+        self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js')
+
+    def test_buefy_configured_only(self):
+        url = util.get_liburl(self.request, 'buefy', configured_only=True)
+        self.assertIsNone(url)
+
     def test_buefy_css_default(self):
         url = util.get_liburl(self.request, 'buefy.css')
         self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css')
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
index 0968052..310c214 100644
--- a/tests/views/test_settings.py
+++ b/tests/views/test_settings.py
@@ -8,11 +8,16 @@ from wuttaweb.views import settings
 class TestAppInfoView(WebTestCase):
 
     def test_index(self):
-        # just a sanity check
+        # sanity/coverage check
         view = settings.AppInfoView(self.request)
         response = view.index()
 
     def test_configure_get_simple_settings(self):
-        # just a sanity check
+        # sanity/coverage check
         view = settings.AppInfoView(self.request)
         simple = view.configure_get_simple_settings()
+
+    def test_configure_get_context(self):
+        # sanity/coverage check
+        view = settings.AppInfoView(self.request)
+        context = view.configure_get_context()

From 23d227b2c648d6fcdc087e044e86b1a3982db4b1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 6 Aug 2024 22:33:54 -0500
Subject: [PATCH 3/3] feat: add basic support for fanstatic / libcache

---
 pyproject.toml                            |  1 +
 src/wuttaweb/app.py                       |  2 +
 src/wuttaweb/util.py                      | 33 ++++++++
 tests/libcache/bb_fontawesome_svg_core.js |  0
 tests/libcache/bb_free_solid_svg_icons.js |  0
 tests/libcache/bb_oruga.js                |  0
 tests/libcache/bb_oruga_bulma.css         |  0
 tests/libcache/bb_oruga_bulma.js          |  0
 tests/libcache/bb_vue.js                  |  0
 tests/libcache/bb_vue_fontawesome.js      |  0
 tests/libcache/buefy.css                  |  0
 tests/libcache/buefy.js                   |  0
 tests/libcache/fontawesome.js             |  0
 tests/libcache/vue.js                     |  0
 tests/libcache/vue_resource.js            |  0
 tests/test_util.py                        | 95 ++++++++++++++++++++++-
 16 files changed, 128 insertions(+), 3 deletions(-)
 create mode 100644 tests/libcache/bb_fontawesome_svg_core.js
 create mode 100644 tests/libcache/bb_free_solid_svg_icons.js
 create mode 100644 tests/libcache/bb_oruga.js
 create mode 100644 tests/libcache/bb_oruga_bulma.css
 create mode 100644 tests/libcache/bb_oruga_bulma.js
 create mode 100644 tests/libcache/bb_vue.js
 create mode 100644 tests/libcache/bb_vue_fontawesome.js
 create mode 100644 tests/libcache/buefy.css
 create mode 100644 tests/libcache/buefy.js
 create mode 100644 tests/libcache/fontawesome.js
 create mode 100644 tests/libcache/vue.js
 create mode 100644 tests/libcache/vue_resource.js

diff --git a/pyproject.toml b/pyproject.toml
index 0aa3e33..a90fbe1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
         "pyramid>=2",
         "pyramid_beaker",
         "pyramid_deform",
+        "pyramid_fanstatic",
         "pyramid_mako",
         "pyramid_tm",
         "waitress",
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index 8b4a610..bafc921 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -114,6 +114,7 @@ def make_pyramid_config(settings):
     :returns: Instance of
        :class:`pyramid:pyramid.config.Configurator`.
     """
+    settings.setdefault('fanstatic.versioning', 'true')
     settings.setdefault('mako.directories', ['wuttaweb:templates'])
     settings.setdefault('pyramid_deform.template_search_path',
                         'wuttaweb:templates/deform')
@@ -130,6 +131,7 @@ def make_pyramid_config(settings):
 
     pyramid_config.include('pyramid_beaker')
     pyramid_config.include('pyramid_deform')
+    pyramid_config.include('pyramid_fanstatic')
     pyramid_config.include('pyramid_mako')
     pyramid_config.include('pyramid_tm')
 
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 61614ad..6d1d5f2 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -248,40 +248,73 @@ def get_liburl(
 
     version = get_libver(request, key, prefix=prefix)
 
+    static = config.get('wuttaweb.static_libcache.module')
+    if static:
+        static = importlib.import_module(static)
+        needed = request.environ['fanstatic.needed']
+        liburl = needed.library_url(static.libcache) + '/'
+        # nb. add custom url prefix if needed, e.g. /wutta
+        if request.script_name:
+            liburl = request.script_name + liburl
+
     if key == 'buefy':
+        if static and hasattr(static, 'buefy_js'):
+            return liburl + static.buefy_js.relpath
         return f'https://unpkg.com/buefy@{version}/dist/buefy.min.js'
 
     elif key == 'buefy.css':
+        if static and hasattr(static, 'buefy_css'):
+            return liburl + static.buefy_css.relpath
         return f'https://unpkg.com/buefy@{version}/dist/buefy.min.css'
 
     elif key == 'vue':
+        if static and hasattr(static, 'vue_js'):
+            return liburl + static.vue_js.relpath
         return f'https://unpkg.com/vue@{version}/dist/vue.min.js'
 
     elif key == 'vue_resource':
+        if static and hasattr(static, 'vue_resource_js'):
+            return liburl + static.vue_resource_js.relpath
         return f'https://cdn.jsdelivr.net/npm/vue-resource@{version}'
 
     elif key == 'fontawesome':
+        if static and hasattr(static, 'fontawesome_js'):
+            return liburl + static.fontawesome_js.relpath
         return f'https://use.fontawesome.com/releases/v{version}/js/all.js'
 
     elif key == 'bb_vue':
+        if static and hasattr(static, 'bb_vue_js'):
+            return liburl + static.bb_vue_js.relpath
         return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js'
 
     elif key == 'bb_oruga':
+        if static and hasattr(static, 'bb_oruga_js'):
+            return liburl + static.bb_oruga_js.relpath
         return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs'
 
     elif key == 'bb_oruga_bulma':
+        if static and hasattr(static, 'bb_oruga_bulma_js'):
+            return liburl + static.bb_oruga_bulma_js.relpath
         return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs'
 
     elif key == 'bb_oruga_bulma_css':
+        if static and hasattr(static, 'bb_oruga_bulma_css'):
+            return liburl + static.bb_oruga_bulma_css.relpath
         return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css'
 
     elif key == 'bb_fontawesome_svg_core':
+        if static and hasattr(static, 'bb_fontawesome_svg_core_js'):
+            return liburl + static.bb_fontawesome_svg_core_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm'
 
     elif key == 'bb_free_solid_svg_icons':
+        if static and hasattr(static, 'bb_free_solid_svg_icons_js'):
+            return liburl + static.bb_free_solid_svg_icons_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm'
 
     elif key == 'bb_vue_fontawesome':
+        if static and hasattr(static, 'bb_vue_fontawesome_js'):
+            return liburl + static.bb_vue_fontawesome_js.relpath
         return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm'
 
 
diff --git a/tests/libcache/bb_fontawesome_svg_core.js b/tests/libcache/bb_fontawesome_svg_core.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_free_solid_svg_icons.js b/tests/libcache/bb_free_solid_svg_icons.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_oruga.js b/tests/libcache/bb_oruga.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_oruga_bulma.css b/tests/libcache/bb_oruga_bulma.css
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_oruga_bulma.js b/tests/libcache/bb_oruga_bulma.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_vue.js b/tests/libcache/bb_vue.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/bb_vue_fontawesome.js b/tests/libcache/bb_vue_fontawesome.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/buefy.css b/tests/libcache/buefy.css
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/buefy.js b/tests/libcache/buefy.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/fontawesome.js b/tests/libcache/fontawesome.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/vue.js b/tests/libcache/vue.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/libcache/vue_resource.js b/tests/libcache/vue_resource.js
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_util.py b/tests/test_util.py
index 44817e5..4d779c3 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,8 +1,9 @@
 # -*- coding: utf-8; -*-
 
 from unittest import TestCase
-from unittest.mock import patch
+from unittest.mock import patch, MagicMock
 
+from fanstatic import Library, Resource
 from pyramid import testing
 
 from wuttjamaican.conf import WuttaConfig
@@ -162,12 +163,40 @@ class TestGetLibVer(TestCase):
         self.assertEqual(version, '3.0.8')
 
 
+libcache = Library('testing', 'libcache')
+vue_js = Resource(libcache, 'vue.js')
+vue_resource_js = Resource(libcache, 'vue_resource.js')
+buefy_js = Resource(libcache, 'buefy.js')
+buefy_css = Resource(libcache, 'buefy.css')
+fontawesome_js = Resource(libcache, 'fontawesome.js')
+bb_vue_js = Resource(libcache, 'bb_vue.js')
+bb_oruga_js = Resource(libcache, 'bb_oruga.js')
+bb_oruga_bulma_js = Resource(libcache, 'bb_oruga_bulma.js')
+bb_oruga_bulma_css = Resource(libcache, 'bb_oruga_bulma.css')
+bb_fontawesome_svg_core_js = Resource(libcache, 'bb_fontawesome_svg_core.js')
+bb_free_solid_svg_icons_js = Resource(libcache, 'bb_free_solid_svg_icons.js')
+bb_vue_fontawesome_js = Resource(libcache, 'bb_vue_fontawesome.js')
+
+
 class TestGetLibUrl(TestCase):
 
     def setUp(self):
         self.config = WuttaConfig()
-        self.request = testing.DummyRequest()
-        self.request.wutta_config = self.config
+        self.request = testing.DummyRequest(wutta_config=self.config)
+        self.pyramid_config = testing.setUp(request=self.request)
+
+    def tearDown(self):
+        testing.tearDown()
+
+    def setup_fanstatic(self):
+        self.pyramid_config.include('pyramid_fanstatic')
+        self.config.setdefault('wuttaweb.static_libcache.module',
+                               'tests.test_util')
+
+        needed = MagicMock()
+        needed.library_url = MagicMock(return_value='/fanstatic')
+        self.request.environ['fanstatic.needed'] = needed
+        self.request.script_name = '/wutta'
 
     def test_buefy_default(self):
         url = util.get_liburl(self.request, 'buefy')
@@ -187,6 +216,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'buefy', configured_only=True)
         self.assertIsNone(url)
 
+    def test_buefy_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'buefy')
+        self.assertEqual(url, '/wutta/fanstatic/buefy.js')
+
     def test_buefy_css_default(self):
         url = util.get_liburl(self.request, 'buefy.css')
         self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css')
@@ -196,6 +230,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'buefy.css')
         self.assertEqual(url, '/lib/buefy.css')
 
+    def test_buefy_css_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'buefy.css')
+        self.assertEqual(url, '/wutta/fanstatic/buefy.css')
+
     def test_vue_default(self):
         url = util.get_liburl(self.request, 'vue')
         self.assertEqual(url, 'https://unpkg.com/vue@2.6.14/dist/vue.min.js')
@@ -205,6 +244,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'vue')
         self.assertEqual(url, '/lib/vue.js')
 
+    def test_vue_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'vue')
+        self.assertEqual(url, '/wutta/fanstatic/vue.js')
+
     def test_vue_resource_default(self):
         url = util.get_liburl(self.request, 'vue_resource')
         self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/vue-resource@latest')
@@ -214,6 +258,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'vue_resource')
         self.assertEqual(url, '/lib/vue-resource.js')
 
+    def test_vue_resource_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'vue_resource')
+        self.assertEqual(url, '/wutta/fanstatic/vue_resource.js')
+
     def test_fontawesome_default(self):
         url = util.get_liburl(self.request, 'fontawesome')
         self.assertEqual(url, 'https://use.fontawesome.com/releases/v5.3.1/js/all.js')
@@ -223,6 +272,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'fontawesome')
         self.assertEqual(url, '/lib/fontawesome.js')
 
+    def test_fontawesome_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'fontawesome')
+        self.assertEqual(url, '/wutta/fanstatic/fontawesome.js')
+
     def test_bb_vue_default(self):
         url = util.get_liburl(self.request, 'bb_vue')
         self.assertEqual(url, 'https://unpkg.com/vue@3.4.31/dist/vue.esm-browser.prod.js')
@@ -232,6 +286,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_vue')
         self.assertEqual(url, '/lib/vue.js')
 
+    def test_bb_vue_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_vue')
+        self.assertEqual(url, '/wutta/fanstatic/bb_vue.js')
+
     def test_bb_oruga_default(self):
         url = util.get_liburl(self.request, 'bb_oruga')
         self.assertEqual(url, 'https://unpkg.com/@oruga-ui/oruga-next@0.8.12/dist/oruga.mjs')
@@ -241,6 +300,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_oruga')
         self.assertEqual(url, '/lib/oruga.js')
 
+    def test_bb_oruga_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_oruga')
+        self.assertEqual(url, '/wutta/fanstatic/bb_oruga.js')
+
     def test_bb_oruga_bulma_default(self):
         url = util.get_liburl(self.request, 'bb_oruga_bulma')
         self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.mjs')
@@ -250,6 +314,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_oruga_bulma')
         self.assertEqual(url, '/lib/oruga_bulma.js')
 
+    def test_bb_oruga_bulma_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_oruga_bulma')
+        self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.js')
+
     def test_bb_oruga_bulma_css_default(self):
         url = util.get_liburl(self.request, 'bb_oruga_bulma_css')
         self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.css')
@@ -259,6 +328,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_oruga_bulma_css')
         self.assertEqual(url, '/lib/oruga-bulma.css')
 
+    def test_bb_oruga_bulma_css_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_oruga_bulma_css')
+        self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.css')
+
     def test_bb_fontawesome_svg_core_default(self):
         url = util.get_liburl(self.request, 'bb_fontawesome_svg_core')
         self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@6.5.2/+esm')
@@ -268,6 +342,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_fontawesome_svg_core')
         self.assertEqual(url, '/lib/fontawesome-svg-core.js')
 
+    def test_bb_fontawesome_svg_core_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_fontawesome_svg_core')
+        self.assertEqual(url, '/wutta/fanstatic/bb_fontawesome_svg_core.js')
+
     def test_bb_free_solid_svg_icons_default(self):
         url = util.get_liburl(self.request, 'bb_free_solid_svg_icons')
         self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@6.5.2/+esm')
@@ -277,6 +356,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_free_solid_svg_icons')
         self.assertEqual(url, '/lib/free-solid-svg-icons.js')
 
+    def test_bb_free_solid_svg_icons_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_free_solid_svg_icons')
+        self.assertEqual(url, '/wutta/fanstatic/bb_free_solid_svg_icons.js')
+
     def test_bb_vue_fontawesome_default(self):
         url = util.get_liburl(self.request, 'bb_vue_fontawesome')
         self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@3.0.6/+esm')
@@ -286,6 +370,11 @@ class TestGetLibUrl(TestCase):
         url = util.get_liburl(self.request, 'bb_vue_fontawesome')
         self.assertEqual(url, '/lib/vue-fontawesome.js')
 
+    def test_bb_vue_fontawesome_fanstatic(self):
+        self.setup_fanstatic()
+        url = util.get_liburl(self.request, 'bb_vue_fontawesome')
+        self.assertEqual(url, '/wutta/fanstatic/bb_vue_fontawesome.js')
+
 
 class TestGetFormData(TestCase):