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. 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()} 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):