From 3b6b3173776829986577133919e170a8e3f171f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 14:55:32 -0500 Subject: [PATCH 01/11] feat: add `util.get_form_data()` convenience function --- src/wuttaweb/util.py | 23 ++++++++++++++++++++++- tests/test_util.py | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 3275301..1cf7804 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -21,12 +21,33 @@ # ################################################################################ """ -Utilities +Web Utilities """ import importlib +def get_form_data(request): + """ + Returns the effective form data for the given request. + + Mostly this is a convenience, which simply returns one of the + following, depending on various attributes of the request. + + * :attr:`pyramid:pyramid.request.Request.POST` + * :attr:`pyramid:pyramid.request.Request.json_body` + """ + # nb. we prefer JSON only if no POST is present + # TODO: this seems to work for our use case at least, but perhaps + # there is a better way? see also + # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr + if not request.POST and ( + getattr(request, 'is_xhr', False) + or request.content_type == 'application/json'): + return request.json_body + return request.POST + + def get_libver( request, key, diff --git a/tests/test_util.py b/tests/test_util.py index d492943..c68d42c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -263,3 +263,30 @@ class TestGetLibUrl(TestCase): self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js') url = util.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/lib/vue-fontawesome.js') + + +class TestGetFormData(TestCase): + + def setUp(self): + self.config = WuttaConfig() + + def make_request(self, **kwargs): + kwargs.setdefault('wutta_config', self.config) + kwargs.setdefault('POST', {'foo1': 'bar'}) + kwargs.setdefault('json_body', {'foo2': 'baz'}) + return testing.DummyRequest(**kwargs) + + def test_default(self): + request = self.make_request() + data = util.get_form_data(request) + self.assertEqual(data, {'foo1': 'bar'}) + + def test_is_xhr(self): + request = self.make_request(POST=None, is_xhr=True) + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) + + def test_content_type(self): + request = self.make_request(POST=None, content_type='application/json') + data = util.get_form_data(request) + self.assertEqual(data, {'foo2': 'baz'}) From 0604651be5c468477073571da1f51c82392dda7d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 15:34:23 -0500 Subject: [PATCH 02/11] feat: add `wuttaweb.db` module, with `Session` --- docs/api/wuttaweb/db.rst | 6 ++++ docs/api/wuttaweb/index.rst | 1 + pyproject.toml | 1 + src/wuttaweb/app.py | 12 +++++-- src/wuttaweb/db.py | 66 +++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 docs/api/wuttaweb/db.rst create mode 100644 src/wuttaweb/db.py diff --git a/docs/api/wuttaweb/db.rst b/docs/api/wuttaweb/db.rst new file mode 100644 index 0000000..b90e227 --- /dev/null +++ b/docs/api/wuttaweb/db.rst @@ -0,0 +1,6 @@ + +``wuttaweb.db`` +=============== + +.. automodule:: wuttaweb.db + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 2e49d4b..1e8ab57 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -8,6 +8,7 @@ :maxdepth: 1 app + db handler helpers menus diff --git a/pyproject.toml b/pyproject.toml index 985bef5..baa0fed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "waitress", "WebHelpers2", "WuttJamaican[db]>=0.7.0", + "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 18b07fb..506bacc 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -31,6 +31,8 @@ from wuttjamaican.conf import make_config from pyramid.config import Configurator +import wuttaweb.db + class WebAppProvider(AppProvider): """ @@ -83,17 +85,21 @@ def make_wutta_config(settings): If this config file path cannot be discovered, an error is raised. """ - # initialize config and embed in settings dict, to make - # available for web requests later + # validate config file path path = settings.get('wutta.config') if not path or not os.path.exists(path): raise ValueError("Please set 'wutta.config' in [app:main] " "section of config to the path of your " "config file. Lame, but necessary.") + # make config per usual, add to settings wutta_config = make_config(path) - settings['wutta_config'] = wutta_config + + # configure database sessions + if hasattr(wutta_config, 'appdb_engine'): + wuttaweb.db.Session.configure(bind=wutta_config.appdb_engine) + return wutta_config diff --git a/src/wuttaweb/db.py b/src/wuttaweb/db.py new file mode 100644 index 0000000..32d2418 --- /dev/null +++ b/src/wuttaweb/db.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Database sessions for web app + +The web app uses a different database session than other +(e.g. console) apps. The web session is "registered" to the HTTP +request/response life cycle (aka. transaction) such that the session +is automatically rolled back on error, and automatically committed if +the response is finalized without error. + +.. class:: Session + + Primary database session class for the web app. + + Note that you often do not need to "instantiate" this session, and + can instead call methods directly on the class:: + + from wuttaweb.db import Session + + users = Session.query(model.User).all() + + However in certain cases you may still want/need to instantiate it, + e.g. when passing a "true/normal" session to other logic. But you + can always call instance methods as well:: + + from wuttaweb.db import Session + from some_place import some_func + + session = Session() + + # nb. assuming func does not expect a "web" session per se, pass instance + some_func(session) + + # nb. these behave the same (instance vs. class method) + users = session.query(model.User).all() + users = Session.query(model.User).all() +""" + +from sqlalchemy import orm +from zope.sqlalchemy.datamanager import register + + +Session = orm.scoped_session(orm.sessionmaker()) + +register(Session) From 95d3623a5e8b0910803639fb16dafab7797453ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 20:35:41 -0500 Subject: [PATCH 03/11] feat: add initial/basic forms support --- docs/api/wuttaweb/forms.base.rst | 6 + docs/api/wuttaweb/forms.rst | 6 + docs/api/wuttaweb/index.rst | 2 + docs/conf.py | 3 + pyproject.toml | 1 + src/wuttaweb/app.py | 4 + src/wuttaweb/forms/__init__.py | 31 ++ src/wuttaweb/forms/base.py | 421 ++++++++++++++++++ src/wuttaweb/templates/deform/password.pt | 8 + src/wuttaweb/templates/deform/textinput.pt | 7 + src/wuttaweb/templates/form.mako | 18 + .../templates/forms/vue_template.mako | 58 +++ src/wuttaweb/views/base.py | 34 +- tests/forms/__init__.py | 0 tests/forms/test_base.py | 241 ++++++++++ tests/views/test_base.py | 28 +- 16 files changed, 858 insertions(+), 10 deletions(-) create mode 100644 docs/api/wuttaweb/forms.base.rst create mode 100644 docs/api/wuttaweb/forms.rst create mode 100644 src/wuttaweb/forms/__init__.py create mode 100644 src/wuttaweb/forms/base.py create mode 100644 src/wuttaweb/templates/deform/password.pt create mode 100644 src/wuttaweb/templates/deform/textinput.pt create mode 100644 src/wuttaweb/templates/form.mako create mode 100644 src/wuttaweb/templates/forms/vue_template.mako create mode 100644 tests/forms/__init__.py create mode 100644 tests/forms/test_base.py diff --git a/docs/api/wuttaweb/forms.base.rst b/docs/api/wuttaweb/forms.base.rst new file mode 100644 index 0000000..8569309 --- /dev/null +++ b/docs/api/wuttaweb/forms.base.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms.base`` +======================= + +.. automodule:: wuttaweb.forms.base + :members: diff --git a/docs/api/wuttaweb/forms.rst b/docs/api/wuttaweb/forms.rst new file mode 100644 index 0000000..1d83240 --- /dev/null +++ b/docs/api/wuttaweb/forms.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms`` +================== + +.. automodule:: wuttaweb.forms + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 1e8ab57..6b305cf 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -9,6 +9,8 @@ app db + forms + forms.base handler helpers menus diff --git a/docs/conf.py b/docs/conf.py index 3955a96..3d568ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,12 +20,15 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', + 'sphinx.ext.todo', ] templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { + 'colander': ('https://docs.pylonsproject.org/projects/colander/en/latest/', None), + 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), 'python': ('https://docs.python.org/3/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), diff --git a/pyproject.toml b/pyproject.toml index baa0fed..ce72e00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ requires-python = ">= 3.8" dependencies = [ "pyramid>=2", "pyramid_beaker", + "pyramid_deform", "pyramid_mako", "waitress", "WebHelpers2", diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 506bacc..a35d00d 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -110,9 +110,13 @@ def make_pyramid_config(settings): The config is initialized with certain features deemed useful for all apps. """ + settings.setdefault('pyramid_deform.template_search_path', + 'wuttaweb:templates/deform') + pyramid_config = Configurator(settings=settings) pyramid_config.include('pyramid_beaker') + pyramid_config.include('pyramid_deform') pyramid_config.include('pyramid_mako') return pyramid_config diff --git a/src/wuttaweb/forms/__init__.py b/src/wuttaweb/forms/__init__.py new file mode 100644 index 0000000..35102be --- /dev/null +++ b/src/wuttaweb/forms/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Forms Library + +The ``wuttaweb.forms`` namespace contains the following: + +* :class:`~wuttaweb.forms.base.Form` +""" + +from .base import Form diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py new file mode 100644 index 0000000..b8c4a40 --- /dev/null +++ b/src/wuttaweb/forms/base.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Base form classes +""" + +import json +import logging + +import colander +import deform +from pyramid.renderers import render +from webhelpers2.html import HTML + +from wuttaweb.util import get_form_data + + +log = logging.getLogger(__name__) + + +class FieldList(list): + """ + Convenience wrapper for a form's field list. This is a subclass + of :class:`python:list`. + + You normally would not need to instantiate this yourself, but it + is used under the hood for e.g. :attr:`Form.fields`. + """ + + def insert_before(self, field, newfield): + """ + Insert a new field, before an existing field. + + :param field: String name for the existing field. + + :param newfield: String name for the new field, to be inserted + just before the existing ``field``. + """ + if field in self: + i = self.index(field) + self.insert(i, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + def insert_after(self, field, newfield): + """ + Insert a new field, after an existing field. + + :param field: String name for the existing field. + + :param newfield: String name for the new field, to be inserted + just after the existing ``field``. + """ + if field in self: + i = self.index(field) + self.insert(i + 1, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + +class Form: + """ + Base class for all forms. + + :param request: Reference to current :term:`request` object. + + :param fields: List of field names for the form. This is + optional; if not specified an attempt will be made to deduce + the list automatically. See also :attr:`fields`. + + :param schema: Colander-based schema object for the form. This is + optional; if not specified an attempt will be made to construct + one automatically. See also :meth:`get_schema()`. + + :param labels: Optional dict of default field labels. + + .. note:: + + Some parameters are not explicitly described above. However + their corresponding attributes are described below. + + Form instances contain the following attributes: + + .. attribute:: fields + + :class:`FieldList` instance containing string field names for + the form. By default, fields will appear in the same order as + they are in this list. + + .. attribute:: request + + Reference to current :term:`request` object. + + .. attribute:: action_url + + String URL to which the form should be submitted, if applicable. + + .. attribute:: vue_tagname + + String name for Vue component tag. By default this is + ``'wutta-form'``. See also :meth:`render_vue_tag()`. + + .. attribute:: align_buttons_right + + Flag indicating whether the buttons (submit, cancel etc.) + should be aligned to the right of the area below the form. If + not set, the buttons are left-aligned. + + .. attribute:: auto_disable_submit + + Flag indicating whether the submit button should be + auto-disabled, whenever the form is submitted. + + .. attribute:: button_label_submit + + String label for the form submit button. Default is ``"Save"``. + + .. attribute:: button_icon_submit + + String icon name for the form submit button. Default is ``'save'``. + + .. attribute:: show_button_reset + + Flag indicating whether a Reset button should be shown. + + .. attribute:: validated + + If the :meth:`validate()` method was called, and it succeeded, + this will be set to the validated data dict. + + Note that in all other cases, this attribute may not exist. + """ + + def __init__( + self, + request, + fields=None, + schema=None, + labels={}, + action_url=None, + vue_tagname='wutta-form', + align_buttons_right=False, + auto_disable_submit=True, + button_label_submit="Save", + button_icon_submit='save', + show_button_reset=False, + ): + self.request = request + self.schema = schema + self.labels = labels or {} + self.action_url = action_url + self.vue_tagname = vue_tagname + self.align_buttons_right = align_buttons_right + self.auto_disable_submit = auto_disable_submit + self.button_label_submit = button_label_submit + self.button_icon_submit = button_icon_submit + self.show_button_reset = show_button_reset + + self.config = self.request.wutta_config + self.app = self.config.get_app() + + if fields is not None: + self.set_fields(fields) + elif self.schema: + self.set_fields([f.name for f in self.schema]) + else: + self.fields = None + + def __contains__(self, name): + """ + Custom logic for the ``in`` operator, to allow easily checking + if the form contains a given field:: + + myform = Form() + if 'somefield' in myform: + print("my form has some field") + """ + return bool(self.fields and name in self.fields) + + def __iter__(self): + """ + Custom logic to allow iterating over form field names:: + + myform = Form(fields=['foo', 'bar']) + for fieldname in myform: + print(fieldname) + """ + return iter(self.fields) + + @property + def vue_component(self): + """ + String name for the Vue component, e.g. ``'WuttaForm'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') + return ''.join([word.capitalize() for word in words]) + + def set_fields(self, fields): + """ + Explicitly set the list of form fields. + + This will overwrite :attr:`fields` with a new + :class:`FieldList` instance. + + :param fields: List of string field names. + """ + self.fields = FieldList(fields) + + def set_label(self, key, label): + """ + Set the label for given field name. + + See also :meth:`get_label()`. + """ + self.labels[key] = label + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].title = label + + def get_label(self, key): + """ + Get the label for given field name. + + Note that this will always return a string, auto-generating + the label if needed. + + See also :meth:`set_label()`. + """ + return self.labels.get(key, self.app.make_title(key)) + + def get_schema(self): + """ + Return the :class:`colander:colander.Schema` object for the + form, generating it automatically if necessary. + """ + if not self.schema: + raise NotImplementedError + + return self.schema + + def get_deform(self): + """ + Return the :class:`deform:deform.Form` instance for the form, + generating it automatically if necessary. + """ + if not hasattr(self, 'deform_form'): + schema = self.get_schema() + form = deform.Form(schema) + self.deform_form = form + + return self.deform_form + + def render_vue_tag(self, **kwargs): + """ + Render the Vue component tag for the form. + + By default this simply returns: + + .. code-block:: html + + <wutta-form></wutta-form> + + The actual output will depend on various form attributes, in + particular :attr:`vue_tagname`. + """ + return HTML.tag(self.vue_tagname, **kwargs) + + def render_vue_template( + self, + template='/forms/vue_template.mako', + **context): + """ + Render the Vue template block for the form. + + This returns something like: + + .. code-block:: none + + <script type="text/x-template" id="wutta-form-template"> + <form> + <!-- fields etc. --> + </form> + </script> + + .. todo:: + + Why can't Sphinx render the above code block as 'html' ? + + It acts like it can't handle a ``<script>`` tag at all? + + Actual output will of course depend on form attributes, i.e. + :attr:`vue_tagname` and :attr:`fields` list etc. + + :param template: Path to Mako template which is used to render + the output. + """ + context['form'] = self + context.setdefault('form_attrs', {}) + + # auto disable button on submit + if self.auto_disable_submit: + context['form_attrs']['@submit'] = 'formSubmitting = true' + + output = render(template, context) + return HTML.literal(output) + + def render_vue_field(self, fieldname): + """ + Render the given field completely, i.e. ``<b-field>`` wrapper + with label and containing a widget. + + Actual output will depend on the field attributes etc. + """ + dform = self.get_deform() + field = dform[fieldname] + + # render the field widget or whatever + html = field.serialize() + html = HTML.literal(html) + + # render field label + label = self.get_label(fieldname) + + # b-field attrs + attrs = { + ':horizontal': 'true', + 'label': label, + } + + return HTML.tag('b-field', c=[html], **attrs) + + def get_vue_field_value(self, field): + """ + This method returns a JSON string which will be assigned as + the initial model value for the given field. This JSON will + be written as part of the overall response, to be interpreted + on the client side. + + Again, this must return a *string* such as: + + * ``'null'`` + * ``'{"foo": "bar"}'`` + + In practice this calls :meth:`jsonify_value()` to convert the + ``field.cstruct`` value to string. + """ + if isinstance(field, str): + dform = self.get_deform() + field = dform[field] + + return self.jsonify_value(field.cstruct) + + def jsonify_value(self, value): + """ + Convert a Python value to JSON string. + + See also :meth:`get_vue_field_value()`. + """ + if value is colander.null: + return 'null' + + return json.dumps(value) + + def validate(self): + """ + Try to validate the form. + + This should work whether request data was submitted as classic + POST data, or as JSON body. + + If the form data is valid, this method returns the data dict. + This data dict is also then available on the form object via + the :attr:`validated` attribute. + + However if the data is not valid, ``False`` is returned, and + there will be no :attr:`validated` attribute. + + :returns: Data dict, or ``False``. + """ + if hasattr(self, 'validated'): + del self.validated + + if self.request.method != 'POST': + return False + + dform = self.get_deform() + controls = get_form_data(self.request).items() + + try: + self.validated = dform.validate(controls) + except deform.ValidationFailure: + return False + + return self.validated diff --git a/src/wuttaweb/templates/deform/password.pt b/src/wuttaweb/templates/deform/password.pt new file mode 100644 index 0000000..7a56879 --- /dev/null +++ b/src/wuttaweb/templates/deform/password.pt @@ -0,0 +1,8 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'model_'+name;"> + <b-input name="${name}" + v-model="${vmodel}" + type="password" + tal:attributes="attributes|field.widget.attributes|{};" /> +</div> diff --git a/src/wuttaweb/templates/deform/textinput.pt b/src/wuttaweb/templates/deform/textinput.pt new file mode 100644 index 0000000..4f09fc6 --- /dev/null +++ b/src/wuttaweb/templates/deform/textinput.pt @@ -0,0 +1,7 @@ +<div tal:omit-tag="" + tal:define="name name|field.name; + vmodel vmodel|'model_'+name;"> + <b-input name="${name}" + v-model="${vmodel}" + tal:attributes="attributes|field.widget.attributes|{};" /> +</div> diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako new file mode 100644 index 0000000..efa320f --- /dev/null +++ b/src/wuttaweb/templates/form.mako @@ -0,0 +1,18 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + ${form.render_vue_template()} +</%def> + +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} + <script> + ${form.vue_component}.data = function() { return ${form.vue_component}Data } + Vue.component('${form.vue_tagname}', ${form.vue_component}) + </script> +</%def> + + +${parent.body()} diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako new file mode 100644 index 0000000..0151632 --- /dev/null +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -0,0 +1,58 @@ +## -*- coding: utf-8; -*- + +<script type="text/x-template" id="${form.vue_tagname}-template"> + ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)} + + <section> + % for fieldname in form: + ${form.render_vue_field(fieldname)} + % endfor + </section> + + <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;"> + + % if form.show_button_reset: + <b-button native-type="reset"> + Reset + </b-button> + % endif + + <b-button type="is-primary" + native-type="submit" + % if form.auto_disable_submit: + :disabled="formSubmitting" + % endif + icon-pack="fas" + icon-left="${form.button_icon_submit}"> + % if form.auto_disable_submit: + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + % else: + ${form.button_label_submit} + % endif + </b-button> + + </div> + + ${h.end_form()} +</script> + +<script> + + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', + methods: {}, + } + + let ${form.vue_component}Data = { + + ## field model values + % for key in form: + model_${key}: ${form.get_vue_field_value(key)|n}, + % endfor + + % if form.auto_disable_submit: + formSubmitting: false, + % endif + } + +</script> diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index 3906c0b..e7bfea3 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -24,6 +24,10 @@ Base Logic for Views """ +from pyramid import httpexceptions + +from wuttaweb import forms + class View: """ @@ -35,8 +39,7 @@ class View: .. attribute:: request - Reference to the current - :class:`pyramid:pyramid.request.Request` object. + Reference to the current :term:`request` object. .. attribute:: app @@ -51,3 +54,30 @@ class View: self.request = request self.config = self.request.wutta_config self.app = self.config.get_app() + + def make_form(self, **kwargs): + """ + Make and return a new :class:`~wuttaweb.forms.base.Form` + instance, per the given ``kwargs``. + + This is the "default" form factory which merely invokes + the constructor. + """ + return forms.Form(self.request, **kwargs) + + def redirect(self, url, **kwargs): + """ + Convenience method to return a HTTP 302 response. + + Note that this technically returns an "exception" - so in + your code, you can either return that error, or raise it:: + + return self.redirect('/') + # ..or + raise self.redirect('/') + + Which you should do will depend on context, but raising the + error is always "safe" since Pyramid will handle that + correctly no matter what. + """ + return httpexceptions.HTTPFound(location=url, **kwargs) diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py new file mode 100644 index 0000000..96a0805 --- /dev/null +++ b/tests/forms/test_base.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +import colander +import deform +from pyramid import testing + +from wuttjamaican.conf import WuttaConfig +from wuttaweb.forms import base +from wuttaweb import helpers + + +class TestFieldList(TestCase): + + def test_insert_before(self): + fields = base.FieldList(['f1', 'f2']) + self.assertEqual(fields, ['f1', 'f2']) + + # typical + fields.insert_before('f1', 'XXX') + self.assertEqual(fields, ['XXX', 'f1', 'f2']) + fields.insert_before('f2', 'YYY') + self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2']) + + # appends new field if reference field is invalid + fields.insert_before('f3', 'ZZZ') + self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ']) + + def test_insert_after(self): + fields = base.FieldList(['f1', 'f2']) + self.assertEqual(fields, ['f1', 'f2']) + + # typical + fields.insert_after('f1', 'XXX') + self.assertEqual(fields, ['f1', 'XXX', 'f2']) + fields.insert_after('XXX', 'YYY') + self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2']) + + # appends new field if reference field is invalid + fields.insert_after('f3', 'ZZZ') + self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ']) + + +class TestForm(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.request = testing.DummyRequest(wutta_config=self.config) + + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'mako.directories': ['wuttaweb:templates'], + 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', + }) + + def tearDown(self): + testing.tearDown() + + def make_form(self, request=None, **kwargs): + return base.Form(request or self.request, **kwargs) + + def make_schema(self): + schema = colander.Schema(children=[ + colander.SchemaNode(colander.String(), + name='foo'), + colander.SchemaNode(colander.String(), + name='bar'), + ]) + return schema + + def test_init_with_none(self): + form = self.make_form() + self.assertIsNone(form.fields) + + def test_init_with_fields(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.fields, ['foo', 'bar']) + + def test_init_with_schema(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + self.assertEqual(form.fields, ['foo', 'bar']) + + def test_vue_tagname(self): + form = self.make_form() + self.assertEqual(form.vue_tagname, 'wutta-form') + + def test_vue_component(self): + form = self.make_form() + self.assertEqual(form.vue_component, 'WuttaForm') + + def test_contains(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertIn('foo', form) + self.assertNotIn('baz', form) + + def test_iter(self): + form = self.make_form(fields=['foo', 'bar']) + + fields = list(iter(form)) + self.assertEqual(fields, ['foo', 'bar']) + + fields = [] + for field in form: + fields.append(field) + self.assertEqual(fields, ['foo', 'bar']) + + def test_set_fields(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.fields, ['foo', 'bar']) + form.set_fields(['baz']) + self.assertEqual(form.fields, ['baz']) + + def test_get_schema(self): + form = self.make_form() + self.assertIsNone(form.schema) + + # provided schema is returned + schema = self.make_schema() + form = self.make_form(schema=schema) + self.assertIs(form.schema, schema) + self.assertIs(form.get_schema(), schema) + + # auto-generating schema not yet supported + form = self.make_form(fields=['foo', 'bar']) + self.assertIsNone(form.schema) + self.assertRaises(NotImplementedError, form.get_schema) + + def test_get_deform(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + self.assertFalse(hasattr(form, 'deform_form')) + dform = form.get_deform() + self.assertIsInstance(dform, deform.Form) + self.assertIs(form.deform_form, dform) + + def test_get_label(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.get_label('foo'), "Foo") + form.set_label('foo', "Baz") + self.assertEqual(form.get_label('foo'), "Baz") + + def test_set_label(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.get_label('foo'), "Foo") + form.set_label('foo', "Baz") + self.assertEqual(form.get_label('foo'), "Baz") + + # schema should be updated when setting label + schema = self.make_schema() + form = self.make_form(schema=schema) + form.set_label('foo', "Woohoo") + self.assertEqual(form.get_label('foo'), "Woohoo") + self.assertEqual(schema['foo'].title, "Woohoo") + + def test_render_vue_tag(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + html = form.render_vue_tag() + self.assertEqual(html, '<wutta-form></wutta-form>') + + def test_render_vue_template(self): + self.pyramid_config.include('pyramid_mako') + self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render', + 'pyramid.events.BeforeRender') + + # form button is disabled on @submit by default + schema = self.make_schema() + form = self.make_form(schema=schema) + html = form.render_vue_template() + self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) + self.assertIn('@submit', html) + + # but not if form is configured otherwise + form = self.make_form(schema=schema, auto_disable_submit=False) + html = form.render_vue_template() + self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) + self.assertNotIn('@submit', html) + + def test_render_vue_field(self): + self.pyramid_config.include('pyramid_deform') + + schema = self.make_schema() + form = self.make_form(schema=schema) + html = form.render_vue_field('foo') + self.assertIn('<b-field :horizontal="true" label="Foo">', html) + self.assertIn('<b-input name="foo"', html) + + def test_get_vue_field_value(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + + # null field value + value = form.get_vue_field_value('foo') + self.assertEqual(value, 'null') + + # non-default / explicit value + # TODO: surely need a different approach to set value + dform = form.get_deform() + dform['foo'].cstruct = 'blarg' + value = form.get_vue_field_value('foo') + self.assertEqual(value, '"blarg"') + + def test_jsonify_value(self): + form = self.make_form() + + # null field value + value = form.jsonify_value(colander.null) + self.assertEqual(value, 'null') + value = form.jsonify_value(None) + self.assertEqual(value, 'null') + + # string value + value = form.jsonify_value('blarg') + self.assertEqual(value, '"blarg"') + + def test_validate(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + self.assertFalse(hasattr(form, 'validated')) + + # will not validate unless request is POST + self.request.POST = {'foo': 'blarg', 'bar': 'baz'} + self.request.method = 'GET' + self.assertFalse(form.validate()) + self.request.method = 'POST' + data = form.validate() + self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'}) + + # validating a second type updates form.validated + self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'} + data = form.validate() + self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'}) + self.assertIs(form.validated, data) + + # bad data does not validate + self.request.POST = {'foo': 42, 'bar': None} + self.assertFalse(form.validate()) + dform = form.get_deform() + self.assertEqual(len(dform.error.children), 2) + self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string") diff --git a/tests/views/test_base.py b/tests/views/test_base.py index ef78251..52c717a 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -3,19 +3,31 @@ from unittest import TestCase from pyramid import testing +from pyramid.httpexceptions import HTTPFound from wuttjamaican.conf import WuttaConfig from wuttaweb.views import base +from wuttaweb.forms import Form class TestView(TestCase): - def test_basic(self): - config = WuttaConfig() - request = testing.DummyRequest() - request.wutta_config = config + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + self.request = testing.DummyRequest(wutta_config=self.config) + self.view = base.View(self.request) - view = base.View(request) - self.assertIs(view.request, request) - self.assertIs(view.config, config) - self.assertIs(view.app, config.get_app()) + def test_basic(self): + self.assertIs(self.view.request, self.request) + self.assertIs(self.view.config, self.config) + self.assertIs(self.view.app, self.app) + + def test_make_form(self): + form = self.view.make_form() + self.assertIsInstance(form, Form) + + def test_redirect(self): + error = self.view.redirect('/') + self.assertIsInstance(error, HTTPFound) + self.assertEqual(error.location, '/') From c6f0007908239f739f279f9945fd7b0bff725b16 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 20:39:42 -0500 Subject: [PATCH 04/11] feat: add `wuttaweb.views.essential` module --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.essential.rst | 6 ++++ src/wuttaweb/views/__init__.py | 2 +- src/wuttaweb/views/essential.py | 43 +++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 docs/api/wuttaweb/views.essential.rst create mode 100644 src/wuttaweb/views/essential.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 6b305cf..5a65f11 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -20,3 +20,4 @@ views views.base views.common + views.essential diff --git a/docs/api/wuttaweb/views.essential.rst b/docs/api/wuttaweb/views.essential.rst new file mode 100644 index 0000000..79c0b57 --- /dev/null +++ b/docs/api/wuttaweb/views.essential.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.essential`` +============================ + +.. automodule:: wuttaweb.views.essential + :members: diff --git a/src/wuttaweb/views/__init__.py b/src/wuttaweb/views/__init__.py index 0b62a83..68fdd77 100644 --- a/src/wuttaweb/views/__init__.py +++ b/src/wuttaweb/views/__init__.py @@ -33,4 +33,4 @@ from .base import View def includeme(config): - config.include('wuttaweb.views.common') + config.include('wuttaweb.views.essential') diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py new file mode 100644 index 0000000..a9272f4 --- /dev/null +++ b/src/wuttaweb/views/essential.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Essential views for convenient includes + +Most apps should include this module:: + + pyramid_config.include('wuttaweb.views.essential') + +That will in turn include the following modules: + +* :mod:`wuttaweb.views.common` +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('wuttaweb.views.common')) + + +def includeme(config): + defaults(config) From e296b50aa461d7ec39ad73a1287b9a80c1f1e814 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 21:54:46 -0500 Subject: [PATCH 05/11] feat: add custom security policy, login/logout for pyramid aka. the `wuttaweb.auth` module --- docs/api/wuttaweb/auth.rst | 6 ++ docs/api/wuttaweb/index.rst | 1 + src/wuttaweb/app.py | 4 + src/wuttaweb/auth.py | 146 ++++++++++++++++++++++++++++++++++++ tests/test_auth.py | 139 ++++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 docs/api/wuttaweb/auth.rst create mode 100644 src/wuttaweb/auth.py create mode 100644 tests/test_auth.py diff --git a/docs/api/wuttaweb/auth.rst b/docs/api/wuttaweb/auth.rst new file mode 100644 index 0000000..d645c67 --- /dev/null +++ b/docs/api/wuttaweb/auth.rst @@ -0,0 +1,6 @@ + +``wuttaweb.auth`` +================= + +.. automodule:: wuttaweb.auth + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 5a65f11..8ee6ff4 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -8,6 +8,7 @@ :maxdepth: 1 app + auth db forms forms.base diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index a35d00d..f8bfc3a 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -32,6 +32,7 @@ from wuttjamaican.conf import make_config from pyramid.config import Configurator import wuttaweb.db +from wuttaweb.auth import WuttaSecurityPolicy class WebAppProvider(AppProvider): @@ -115,6 +116,9 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings) + # configure user authorization / authentication + pyramid_config.set_security_policy(WuttaSecurityPolicy()) + pyramid_config.include('pyramid_beaker') pyramid_config.include('pyramid_deform') pyramid_config.include('pyramid_mako') diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py new file mode 100644 index 0000000..0c2f26d --- /dev/null +++ b/src/wuttaweb/auth.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Auth Utility Logic +""" + +import re + +from pyramid.authentication import SessionAuthenticationHelper +from pyramid.request import RequestLocalCache +from pyramid.security import remember, forget + +from wuttaweb.db import Session + + +def login_user(request, user): + """ + Perform the steps necessary to "login" the given user. This + returns a ``headers`` dict which you should pass to the final + redirect, like so:: + + from pyramid.httpexceptions import HTTPFound + + headers = login_user(request, user) + return HTTPFound(location='/', headers=headers) + + .. warning:: + + This logic does not "authenticate" the user! It assumes caller + has already authenticated the user and they are safe to login. + + See also :func:`logout_user()`. + """ + headers = remember(request, user.uuid) + return headers + + +def logout_user(request): + """ + Perform the logout action for the given request. This returns a + ``headers`` dict which you should pass to the final redirect, like + so:: + + from pyramid.httpexceptions import HTTPFound + + headers = logout_user(request) + return HTTPFound(location='/', headers=headers) + + See also :func:`login_user()`. + """ + request.session.delete() + request.session.invalidate() + headers = forget(request) + return headers + + +class WuttaSecurityPolicy: + """ + Pyramid :term:`security policy` for WuttaWeb. + + For more on the Pyramid details, see :doc:`pyramid:narr/security`. + + But the idea here is that you should be able to just use this, + without thinking too hard:: + + from pyramid.config import Configurator + from wuttaweb.auth import WuttaSecurityPolicy + + pyramid_config = Configurator() + pyramid_config.set_security_policy(WuttaSecurityPolicy()) + + This security policy will then do the following: + + * use the request "web session" for auth storage (e.g. current + ``user.uuid``) + * check permissions as needed, by calling + :meth:`~wuttjamaican:wuttjamaican.auth.AuthHandler.has_permission()` + for current user + + :param db_session: Optional :term:`db session` to use, instead of + :class:`wuttaweb.db.Session`. Probably only useful for tests. + """ + + def __init__(self, db_session=None): + self.session_helper = SessionAuthenticationHelper() + self.identity_cache = RequestLocalCache(self.load_identity) + self.db_session = db_session or Session() + + def load_identity(self, request): + config = request.registry.settings['wutta_config'] + app = config.get_app() + model = app.model + + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + + # fetch user object from db + user = self.db_session.get(model.User, uuid) + if not user: + return + + return user + + def identity(self, request): + return self.identity_cache.get_or_create(request) + + def authenticated_userid(self, request): + user = self.identity(request) + if user is not None: + return user.uuid + + def remember(self, request, userid, **kw): + return self.session_helper.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.session_helper.forget(request, **kw) + + def permits(self, request, context, permission): + config = request.registry.settings['wutta_config'] + app = config.get_app() + auth = app.get_auth_handler() + + user = self.identity(request) + return auth.has_permission(self.db_session, user, permission) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..a6bea29 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import MagicMock + +from pyramid import testing + +from wuttjamaican.conf import WuttaConfig +from wuttaweb import auth as mod + + +class TestLoginUser(TestCase): + + def test_basic(self): + config = WuttaConfig() + app = config.get_app() + model = app.model + request = testing.DummyRequest(wutta_config=config) + user = model.User(username='barney') + headers = mod.login_user(request, user) + self.assertEqual(headers, []) + +class TestLogoutUser(TestCase): + + def test_basic(self): + config = WuttaConfig() + request = testing.DummyRequest(wutta_config=config) + request.session.delete = MagicMock() + headers = mod.logout_user(request) + request.session.delete.assert_called_once_with() + self.assertEqual(headers, []) + + +class TestWuttaSecurityPolicy(TestCase): + + def setUp(self): + self.config = WuttaConfig(defaults={ + 'wutta.db.default.url': 'sqlite://', + }) + + self.request = testing.DummyRequest() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + self.app = self.config.get_app() + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + self.session = self.app.make_session() + self.user = model.User(username='barney') + self.session.add(self.user) + self.session.commit() + + self.policy = self.make_policy() + + def tearDown(self): + testing.tearDown() + + def make_policy(self): + return mod.WuttaSecurityPolicy(db_session=self.session) + + def test_remember(self): + uuid = self.user.uuid + self.assertIsNotNone(uuid) + self.assertIsNone(self.policy.session_helper.authenticated_userid(self.request)) + self.policy.remember(self.request, uuid) + self.assertEqual(self.policy.session_helper.authenticated_userid(self.request), uuid) + + def test_forget(self): + uuid = self.user.uuid + self.policy.remember(self.request, uuid) + self.assertEqual(self.policy.session_helper.authenticated_userid(self.request), uuid) + self.policy.forget(self.request) + self.assertIsNone(self.policy.session_helper.authenticated_userid(self.request)) + + def test_identity(self): + + # no identity + user = self.policy.identity(self.request) + self.assertIsNone(user) + + # identity is remembered (must use new policy to bust cache) + self.policy = self.make_policy() + uuid = self.user.uuid + self.assertIsNotNone(uuid) + self.policy.remember(self.request, uuid) + user = self.policy.identity(self.request) + self.assertIs(user, self.user) + + # invalid identity yields no user + self.policy = self.make_policy() + self.policy.remember(self.request, 'bogus-user-uuid') + user = self.policy.identity(self.request) + self.assertIsNone(user) + + def test_authenticated_userid(self): + + # no identity + uuid = self.policy.authenticated_userid(self.request) + self.assertIsNone(uuid) + + # identity is remembered (must use new policy to bust cache) + self.policy = self.make_policy() + self.policy.remember(self.request, self.user.uuid) + uuid = self.policy.authenticated_userid(self.request) + self.assertEqual(uuid, self.user.uuid) + + def test_permits(self): + auth = self.app.get_auth_handler() + model = self.app.model + + # anon has no perms + self.assertFalse(self.policy.permits(self.request, None, 'foo.bar')) + + # but we can grant it + anons = auth.get_role_anonymous(self.session) + self.user.roles.append(anons) + auth.grant_permission(anons, 'foo.bar') + self.session.commit() + + # and then perm check is satisfied + self.assertTrue(self.policy.permits(self.request, None, 'foo.bar')) + + # now, create a separate role and grant another perm + # (but user does not yet belong to this role) + role = model.Role(name='whatever') + self.session.add(role) + auth.grant_permission(role, 'baz.edit') + self.session.commit() + + # so far then, user does not have the permission + self.policy = self.make_policy() + self.policy.remember(self.request, self.user.uuid) + self.assertFalse(self.policy.permits(self.request, None, 'baz.edit')) + + # but if we assign user to role, perm check should pass + self.user.roles.append(role) + self.session.commit() + self.assertTrue(self.policy.permits(self.request, None, 'baz.edit')) From a505ef27fb908459242c56e232958a976dee4b65 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 23:09:29 -0500 Subject: [PATCH 06/11] feat: add auth views, for login/logout --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.auth.rst | 6 ++ src/wuttaweb/subscribers.py | 44 +++++++- src/wuttaweb/templates/base.mako | 13 ++- src/wuttaweb/templates/login.mako | 46 ++++++++ src/wuttaweb/views/auth.py | 168 ++++++++++++++++++++++++++++++ src/wuttaweb/views/essential.py | 2 + tests/test_subscribers.py | 42 ++++++++ tests/views/test_auth.py | 72 +++++++++++++ 9 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 docs/api/wuttaweb/views.auth.rst create mode 100644 src/wuttaweb/templates/login.mako create mode 100644 src/wuttaweb/views/auth.py create mode 100644 tests/views/test_auth.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 8ee6ff4..204864e 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -19,6 +19,7 @@ subscribers util views + views.auth views.base views.common views.essential diff --git a/docs/api/wuttaweb/views.auth.rst b/docs/api/wuttaweb/views.auth.rst new file mode 100644 index 0000000..9a03e3e --- /dev/null +++ b/docs/api/wuttaweb/views.auth.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.auth`` +======================= + +.. automodule:: wuttaweb.views.auth + :members: diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index eea26d2..eebefb4 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -41,6 +41,7 @@ import logging from pyramid import threadlocal from wuttaweb import helpers +from wuttaweb.db import Session log = logging.getLogger(__name__) @@ -48,7 +49,7 @@ log = logging.getLogger(__name__) def new_request(event): """ - Event hook called when processing a new request. + Event hook called when processing a new :term:`request`. The hook is auto-registered if this module is "included" by Pyramid config object. Or you can explicitly register it:: @@ -56,7 +57,7 @@ def new_request(event): pyramid_config.add_subscriber('wuttaweb.subscribers.new_request', 'pyramid.events.NewRequest') - This will add some things to the request object: + This will add to the request object: .. attribute:: request.wutta_config @@ -66,7 +67,7 @@ def new_request(event): Flag indicating whether the frontend should be displayed using Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if - ``False``). + ``False``). This flag is ``False`` by default. """ request = event.request config = request.registry.settings['wutta_config'] @@ -84,6 +85,42 @@ def new_request(event): request.set_property(use_oruga, reify=True) +def new_request_set_user(event, db_session=None): + """ + Event hook called when processing a new :term:`request`, for sake + of setting the ``request.user`` property. + + The hook is auto-registered if this module is "included" by + Pyramid config object. Or you can explicitly register it:: + + pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user', + 'pyramid.events.NewRequest') + + This will add to the request object: + + .. attribute:: request.user + + Reference to the authenticated + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance + (if logged in), or ``None``. + + :param db_session: Optional :term:`db session` to use, instead of + :class:`wuttaweb.db.Session`. Probably only useful for tests. + """ + request = event.request + config = request.registry.settings['wutta_config'] + app = config.get_app() + model = app.model + + def user(request): + uuid = request.authenticated_userid + if uuid: + session = db_session or Session() + return session.get(model.User, uuid) + + request.set_property(user, reify=True) + + def before_render(event): """ Event hook called just before rendering a template. @@ -151,4 +188,5 @@ def before_render(event): def includeme(config): config.add_subscriber(new_request, 'pyramid.events.NewRequest') + config.add_subscriber(new_request_set_user, 'pyramid.events.NewRequest') config.add_subscriber(before_render, 'pyramid.events.BeforeRender') diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 5511195..f6b4206 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -309,7 +309,18 @@ </div> </%def> -<%def name="render_user_menu()"></%def> +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${request.user}</a> + <div class="navbar-dropdown"> + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> <%def name="render_instance_header_title_extras()"></%def> diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/login.mako new file mode 100644 index 0000000..b50a863 --- /dev/null +++ b/src/wuttaweb/templates/login.mako @@ -0,0 +1,46 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Login</%def> + +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="page_content()"> + <div style="height: 100%; display: flex; align-items: center; justify-content: center;"> + <div class="card"> + <div class="card-content"> + ${form.render_vue_tag()} + </div> + </div> + </div> +</%def> + +<%def name="modify_this_page_vars()"> + <script> + + ${form.vue_component}Data.usernameInput = null + + ${form.vue_component}.mounted = function() { + this.$refs.username.focus() + this.usernameInput = this.$refs.username.$el.querySelector('input') + this.usernameInput.addEventListener('keydown', this.usernameKeydown) + } + + ${form.vue_component}.beforeDestroy = function() { + this.usernameInput.removeEventListener('keydown', this.usernameKeydown) + } + + ${form.vue_component}.methods.usernameKeydown = function(event) { + if (event.which == 13) { // ENTER + event.preventDefault() + this.$refs.password.focus() + } + } + + </script> +</%def> + + +${parent.body()} diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py new file mode 100644 index 0000000..981afbd --- /dev/null +++ b/src/wuttaweb/views/auth.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Auth Views +""" + +import colander +from deform.widget import TextInputWidget, PasswordWidget + +from wuttaweb.views import View +from wuttaweb.db import Session +from wuttaweb.auth import login_user, logout_user + + +class AuthView(View): + """ + Auth views shared by all apps. + """ + + def login(self, session=None): + """ + View for user login. + + This view shows the login form, and handles its submission. + Upon successful login, user is redirected to home page. + + * route: ``login`` + * template: ``/login.mako`` + """ + auth = self.app.get_auth_handler() + + # TODO: should call request.get_referrer() + referrer = self.request.route_url('home') + + # redirect if already logged in + if self.request.user: + self.request.session.flash(f"{self.request.user} is already logged in", 'error') + return self.redirect(referrer) + + form = self.make_form(schema=self.login_make_schema(), + align_buttons_right=True, + show_button_reset=True, + button_label_submit="Login", + button_icon_submit='user') + + # TODO + # form.show_cancel = False + + # validate basic form data (sanity check) + data = form.validate() + if data: + + # truly validate user credentials + session = session or Session() + user = auth.authenticate_user(session, data['username'], data['password']) + if user: + + # okay now they're truly logged in + headers = login_user(self.request, user) + return self.redirect(referrer, headers=headers) + + else: + self.request.session.flash("Invalid user credentials", 'error') + + return { + 'index_title': self.app.get_title(), + 'form': form, + # TODO + # 'referrer': referrer, + } + + def login_make_schema(self): + schema = colander.Schema() + + # nb. we must explicitly declare the widgets in order to also + # specify the ref attribute. this is needed for autofocus and + # keydown behavior for login form. + + schema.add(colander.SchemaNode( + colander.String(), + name='username', + widget=TextInputWidget(attributes={ + 'ref': 'username', + }))) + + schema.add(colander.SchemaNode( + colander.String(), + name='password', + widget=PasswordWidget(attributes={ + 'ref': 'password', + }))) + + return schema + + def logout(self): + """ + View for user logout. + + This deletes/invalidates the current user session and then + redirects to the login page. + + Note that a simple GET is sufficient; POST is not required. + + * route: ``logout`` + * template: n/a + """ + # truly logout the user + headers = logout_user(self.request) + + # TODO + # # redirect to home page after logout, if so configured + # if self.config.get_bool('wuttaweb.home_after_logout', default=False): + # return self.redirect(self.request.route_url('home'), headers=headers) + + # otherwise redirect to referrer, with 'login' page as fallback + # TODO: should call request.get_referrer() + # referrer = self.request.get_referrer(default=self.request.route_url('login')) + referrer = self.request.route_url('login') + return self.redirect(referrer, headers=headers) + + @classmethod + def defaults(cls, config): + cls._auth_defaults(config) + + @classmethod + def _auth_defaults(cls, config): + + # login + config.add_route('login', '/login') + config.add_view(cls, attr='login', + route_name='login', + renderer='/login.mako') + + # logout + config.add_route('logout', '/logout') + config.add_view(cls, attr='logout', + route_name='logout') + + +def defaults(config, **kwargs): + base = globals() + + AuthView = kwargs.get('AuthView', base['AuthView']) + AuthView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index a9272f4..93c8149 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -29,6 +29,7 @@ Most apps should include this module:: That will in turn include the following modules: +* :mod:`wuttaweb.views.auth` * :mod:`wuttaweb.views.common` """ @@ -36,6 +37,7 @@ That will in turn include the following modules: def defaults(config, **kwargs): mod = lambda spec: kwargs.get(spec, spec) + config.include(mod('wuttaweb.views.auth')) config.include(mod('wuttaweb.views.common')) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 804eb1a..63b6640 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -7,9 +7,11 @@ from unittest.mock import MagicMock from wuttjamaican.conf import WuttaConfig from pyramid import testing +from pyramid.security import remember from wuttaweb import subscribers from wuttaweb import helpers +from wuttaweb.auth import WuttaSecurityPolicy class TestNewRequest(TestCase): @@ -56,6 +58,46 @@ def custom_oruga_detector(request): return True +class TestNewRequestSetUser(TestCase): + + def setUp(self): + self.config = WuttaConfig(defaults={ + 'wutta.db.default.url': 'sqlite://', + }) + + self.request = testing.DummyRequest() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + self.app = self.config.get_app() + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + self.session = self.app.make_session() + self.user = model.User(username='barney') + self.session.add(self.user) + self.session.commit() + + self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session)) + + def tearDown(self): + testing.tearDown() + + def test_anonymous(self): + self.assertFalse(hasattr(self.request, 'user')) + event = MagicMock(request=self.request) + subscribers.new_request_set_user(event) + self.assertIsNone(self.request.user) + + def test_authenticated(self): + uuid = self.user.uuid + self.assertIsNotNone(uuid) + remember(self.request, uuid) + event = MagicMock(request=self.request) + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIs(self.request.user, self.user) + + class TestBeforeRender(TestCase): def setUp(self): diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py new file mode 100644 index 0000000..495dac1 --- /dev/null +++ b/tests/views/test_auth.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import MagicMock + +from pyramid import testing +from pyramid.httpexceptions import HTTPFound + +from wuttjamaican.conf import WuttaConfig +from wuttaweb.views import auth as mod +from wuttaweb.auth import WuttaSecurityPolicy + + +class TestAuthView(TestCase): + + def setUp(self): + self.config = WuttaConfig(defaults={ + 'wutta.db.default.url': 'sqlite://', + }) + + self.request = testing.DummyRequest(wutta_config=self.config, user=None) + self.pyramid_config = testing.setUp(request=self.request) + + self.app = self.config.get_app() + auth = self.app.get_auth_handler() + model = self.app.model + model.Base.metadata.create_all(bind=self.config.appdb_engine) + self.session = self.app.make_session() + self.user = model.User(username='barney') + self.session.add(self.user) + auth.set_user_password(self.user, 'testpass') + self.session.commit() + + self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session)) + self.pyramid_config.include('wuttaweb.views.auth') + self.pyramid_config.include('wuttaweb.views.common') + + def tearDown(self): + testing.tearDown() + + def test_login(self): + view = mod.AuthView(self.request) + context = view.login() + self.assertIn('form', context) + + # redirect if user already logged in + self.request.user = self.user + view = mod.AuthView(self.request) + redirect = view.login(session=self.session) + self.assertIsInstance(redirect, HTTPFound) + + # login fails w/ wrong password + self.request.user = None + self.request.method = 'POST' + self.request.POST = {'username': 'barney', 'password': 'WRONG'} + view = mod.AuthView(self.request) + context = view.login(session=self.session) + self.assertIn('form', context) + + # redirect if login succeeds + self.request.method = 'POST' + self.request.POST = {'username': 'barney', 'password': 'testpass'} + view = mod.AuthView(self.request) + redirect = view.login(session=self.session) + self.assertIsInstance(redirect, HTTPFound) + + def test_logout(self): + view = mod.AuthView(self.request) + self.request.session.delete = MagicMock() + redirect = view.logout() + self.request.session.delete.assert_called_once_with() + self.assertIsInstance(redirect, HTTPFound) From 70d13ee1e756ab115b3ab24b12b88181f9424fe4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 08:44:02 -0500 Subject: [PATCH 07/11] feat: add basic logo, favicon images definitely should replace these at some point.. --- src/wuttaweb/static/img/favicon.ico | Bin 0 -> 5694 bytes src/wuttaweb/static/img/logo.png | Bin 0 -> 20687 bytes src/wuttaweb/templates/base.mako | 8 +++++--- src/wuttaweb/templates/base_meta.mako | 8 ++++++-- src/wuttaweb/templates/home.mako | 8 +++----- src/wuttaweb/templates/login.mako | 4 +++- 6 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 src/wuttaweb/static/img/favicon.ico create mode 100644 src/wuttaweb/static/img/logo.png diff --git a/src/wuttaweb/static/img/favicon.ico b/src/wuttaweb/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2b7edf1d739b0f1b0cbd6d9ec186d18843a54c4c GIT binary patch literal 5694 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 literal 0 HcmV?d00001 diff --git a/src/wuttaweb/static/img/logo.png b/src/wuttaweb/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..96fae964432932828436639579f10d04ff4dc5e2 GIT binary patch literal 20687 zcmV)OK(@b$P)<h;3K|Lk000e1NJLTq00Jfe006WI00000g_OAa00009a7bBm00B2_ z00B2_0c7p+8~^|S2XskIMF-sp0Tl!{5#*uO001BWNkl<Zc-ri}SB!00e%`m%+H2={ z_Q|16)u~){bKl!(x+nFJBa%ZAq-aq#K>EpmZP>8j7a4jo0l(M}gdYqG1SDAoB*+Xh zWF`$~hLfk`?e3dz<#=-5Iq$vv&_mAj1PrMW*<sECs&F0-PzC&d`}@BAUkMVh7>n_f z2KtcwKLmgUxC=~v5+#rTxZq@g$xompyX>b6PCj%4f2a%xg17%U^FKA=qAm+ee!P@` z^xhY5)59uF7N9Il=f_fFef71MuMWTCfCVKBHTkiIbMPhi6Uj;W@jSPv%L0=hBPHbi zAEI9r4r;XJq|~A?3rv0_CGpq4v+}v#Deo|KE29?SWRXpNct8;Uhc8}xc{oTQrLD}( z1tkkL`B7q$d~5G>cfRIG1g{2%3caYy0+Sy>N%+T|UsB$2^Hf@k#)*R#VzR*GhbbYi zUHrn@SIi1c)jFsG`q9F47MT1HC6h<~KcxSBST01(%Z$A%W(!Cb5%vcl0r22U;fuA0 z#%Pu_FYb@{ID0`^aPlD=AN(}i@#AWv^hOv>mv0{Qf#}4~GLp35WP!=|8xA<@<ZJZ7 zlMF6D^RzGJ(zs<mSiiC8uPsdH`@`$2d+aiOYKmdy&e>GS&X_AfUnGl_1d9m!eUv0` zkJnr5ePpIrn}2Ft;jPWeSqBS6v?$C%P5vsp#;*@rD!A*mJG<A~7B>%fmoH=TG!Yij zWT7U16-%NAliD(M7IXb;YZvZ@9`1zuS(Gk?j=tbzfys9%@gI%ryUbo&Zojy8Z<=xT zt==VZ%uVq6l?5dW)A=qX)>|{a$?Ofmz0E5li^AOXlXtV>tl67b)*`}wpo4%P+~0sc zFfBRuNnO8WHxI6C9$!+hyxX}{68(7w>}AbGg#ADVB-syi!atC{34tIuoY2MA2`PU| zz0(;u@>=0=N*g61aoge6v&>>r=Yy4#h;ScxHX#s1rxPZ(^|-(FxKw}Yw5414HM=!p z@YFYQm07x24Ew=S0$K3>2NiuFl*E^|i1MdS?L=L_<g)TDY403gva6R}ySRP!C|*?R zgEgJ+{(PWn0zrJ}=x%F1SGtwLxeynM**Aif<E^?IfHHr4xyvpp^`T2EAHW|yy{yNp z&RE@NRyv59KI`7KMa&k=fYsa+RDPzk@SLA{Ob8ra)`-Zw8zQaN5$0gi8uhu!sx<E5 z&15v$+%CXHg8f<Vn-JLEZ)6JDCmpmhoK2aX;iX*hmeFJ))H)~pOZ>aWBEkNwV?tnZ zQt=e;D4cEWO!?k?W8m$cJ7uQ(l-6f6t?BL!7FGFK$ArN6EsdyLJL#8JO*mY-a+oYV zN;P=y?>4Mi{rcd(QR5d!oP5{@{yV0Gyrx?X{=pe4ZuGzaT{)-}jy+{6jb6d`lAG)M z_aLKD3)A^%tI1D!mJxyAojkiFof~udTKD2a-hAlyzLJ%2`QdJI_jIHA^l4>dHq91g z`DkMTllZ53&>{FX4kck|k@9ms_8IlDT6wUFB>nV7`0DtT@|R!LORloI*v<8kHy!v> ziOCU@G}U)Q3Vvb;Pvpi~X=4AqIcwZ|ZV<+wlwU`8m?!;uelhX)QSY0OpMnztPbaXr zboTpo_LC#`T;FahBkxB40STA05N`&Lm0fZ+(KfcZMcd>fo=xCi6RRH)Vh9X;N~l`n z)_1P;*n8SD{nIZQS1-Ai_(~uQOYYsFe)80M85~551p86XCVySmfl&&7P=>t2(5ab^ zS^K{-#e3T~`>MKnXP!TbUmlmnpGzKt>u?^epPp-rX!4Oaoxj05&HJ2#K;XU2B-_GQ z3&&e_;WGV1&m+mk6Q4#U(?9t_?=_+2Q*_y!M4xRes`9gf$q$eC8vw5=J^!H9DfhBZ z?Po5(HVIyTrg{p8*ZW6H<x5(vxjaa&sEcUwGl~g;<kh*>P@b6PHZ;F-<Hfypol#A& zx18%*Lh~WSWgO?hrM6bq(1MbWdN%owx1hFNDDg*bPY?0ov#<31)YskkV4MBQS)u+V zP-wAF)bz~h<Z|)iJc5rw-{dF9kRSPt`DMPMbb}t%tDWVyAH~D<%HDw5Q7`G$%2Afn z0D3WfSZiF#y{2s~+9n@M)5+jZq{KT+@?v*p#`2gJH`*cO>eGJzhSY|u!;7TEs%I-+ zJ8WHzj?X^3y(r4Z5|b2AKZ%lqSeL@LPd8}64O_!D^9%^B{<1Tn)}|+Fm6|6T#-U!@ zn!JmauH+Wk<l~r42u1x!)Cd3o5CR~Gp86#!=>b(x9eOKp2REm0&7)=N5M1qF0G&-D z)u5E!weya|t>zZZwU1vmA@Ikx+~9YAMf3A)l{iDER6oqY!gy%h&iZF;D>K#$BUeMC ztOz6EZkE3`TW1pnEX3qvWIEp~X6&RXNdpiuGu}_4EQ$cf;?zo^%Pa>m*w^9n0K_AS zirSLi>tlrn6CoZi<+3xjNSU+i<f$SqfrB}hEUnZRJ=l+lnvnOlaU`BU9!C9<>CdZ* zZ_EqKei0~<U<Emi<+!Opu+7+y)p2oiNYT1Z2i_VrKh!GrgQi~E4;y|Dsd8N2@rNl` zjxWM0q_!&yG5Hu^@?Ap0PWzl7XFQ+>IB+Cb^o<LQ^qO1DVZI32v>;>@VyjbYWfV3C zaFQ>>)Hb*=dcfo}S6ISr@`8Dxt&Sh9RmL${YxP6MWomkHLCHrPlkXCeb~^3Jq~k~R zT#(7Q%*KUUnsTrTCwalv#mSOo6bMDB%{SOL*K>TynwvohS{aBrgdQ&mkz?&%Sj^4# zYnzfYQ!0lS@)fZ!C8k6RoRMEp^3lbFKmZ^JM;GU(Va$?T*6hoxj(gQ$!GzLf7~4!U z22wE#@_8BNTwKtj4L4{DjahX(W*>fbdVDiyQZ-?hK?m+L!#AJ3F+5scAIBSwQ-}Mc zb6WHZ+IT5DUCl2l^3lYEKmcIW=^Pym8vz$ES0iz&JHL6Ftk8Z|i+G5Q5}#tx&gJcb zbh5?e?P=EM%WZzWb>I}{`D|DwG#jRt5`-L5%VNQLY4;IoDwl(s+B7I#wPxwCCf#TH z$yRYukdGiH1OfoV^EXbO`ua}VTE|L1T?>|jT2baIW0YtCZ*m$Hmy<|H6-8F4PV$*J z%vUZ_S{O;}M!Pj$S)EQ3wI#_iVmhV!yQbhP#fiC&Cdv(dZ??TVord10<_~gx@(FRV z-R~p!LQEh4VDj+odxKT}mb)xM%4j7MWu@Ds{QIi1bl^lO@RPC(#;J}3Bgqx|h3wU5 zmMz4%GYMl!9$o12v051#EW@RviGI1<DfTXZo%Q{&_$v2`zu&p>>_Lyu*Uq`<vUZ(Y z1lW%lCIkWiMqmF@C(>RXf59&Vt&tj8iUf#gVkVH_$uiS_)R00Q6MGQPmbA({xrd)& z3I|pd@VxjSldA%9&*I(GFWD2zX~=Hxf`--T-sL*G6yTPx2M;@U)~DX+4s$9b7cW-f zf|8FECIkWi=5PM#z@T1gwjxTTVGawv5Yf|#?la2?plP4rdB)l<r-Z|K^Ehb=4E2qd z^341qN<JOJe%xy<!`WCL>}2s&iodmc<A~3`&DQ2nxO>}bE0q7K^Z6?MG-^HHyH`8h zzC(irB_AD32n2v+(>K3ySG>A*O^8t;NyoTCGuX%(u@GdLCWj&wI6{Ag>hq~Y=3$ai z7v=o8fDb>_Gg1D4*`?b;<vS}46RIO?J-lcL$M&sDn%(c`*x~YhI#k7R7RzZJB+PBo z3Lk!EdBMm>D8dp5K^mX@zpocIetGvwL{|XBCaJ_ynkGJAr7V#FnIlBRG+hdznuabf z@&UGZ8p@o{WYTy8Z|&PM<l#mTWjT;LUW#Y|<kJoi!OEy{u*#MD4a29x*LQ%oJTSF& zdGwo$BkDgwm=Fj-JpabO=@zbDmHF#QcMvh-l4;~Nmnkve3MQr)OJuAFq-@CwFRN)R z#R0-9CkSm{OS5J2WJLgCDGOu;<flCi04!KW5dfLF;N&z#)F*pZsU3-N7FQ$&Ki*eg zyT9FtGwQuHD+@{a(3=hc5X8n;{%z;ZujUGA)EV?5mZNIY8E?1GUiqBPB{Ya_pB6$n zb9COZ)22-)Okx6-jivM~*D;^Xh5NTaHgSMhf?<!%Gmx|)Q-UChF5O*mcx>J77B0g6 zD(wtn>)9kt_o+s&9P7(;9%6(RsC>vVArOFMdiVD(uUs!)^^-T=9{zp3#EuP0%ux(^ z@|N?Pw-BF>VV;UIDkDk8NyKAO3Ic#2A*#c%P7vtKM3dr)2v6$;GeCo;0!`{rUjb%R ze6_rO#^bO3@_ZaTyd!(QWi*_8_nfY^RR5GZsH>__WEhtEz8Jk2A1>1&0D^Sz)}Qrm z-&)-fjPAd9`yYIsv*H|}nz+<q%-g{Illxz|?o|y0qlh6fQ5k4Qbjnc)n?<r~;}ppv zWp;V&0u~Ee0A>YQ*uRzfmEFB63uquGSyU49<K-Zizxy&v_{n4~5uU%Of;32X_)Gme zy){l*t!7BV^mi|XT)^@n8PspT7fQ18zxacH$36S<&C2rAZ~n3N@*1Hu9*`oN5if`5 zErC1vdaDw|6!A?v<N1uZSYl0>!8oNECWIXHDI%Caof|b8kQ4%$V&?J`$RM_ISO6A+ zehynnVb~PSD5didtwzd<KU~9Nl!elY1#U3M2mSkx=iOOmWaKXc%Yv2<1ttVSF!;*9 z`qSc_+dFkeJosNbg_oZdIi!?05yZO9u!c6D)oSm2buC9|gvBJn{eF@$42mU}@c;)2 z3qclT3?vL-O^qN3ZB~FFbLK4w{28QaFdv00JaE{I(ZJko)+q**pu(*U@}d)^!{;z7 zmuQ94inOu+V0s)s+&}gVGeO`lPsLfl@<Eyofgt+kpP#S%7j>~lbKKM4rMKdB#>)dT z=XnWYDoi=aAO^YprT^`JSp$$z>oYetGdVz-7*ITk5|n^M28@d^qWu0$kX1?~14@A$ zNi2;}^j>8fKrmbqK$Km)43Gk<89NJnT4q25<=!^qMoi6z0ej0z=&jmmS(P4Lbec7W z&(XY)lN6ez7O;GPm=Fl;-~Ii@FFs4D0s|BCcas+n|H}d)U?xMr5SlIpYz&ynTv&bn zYk%@z(*UAaV6|B;hJ(IT=KPGOK!P}wWU3*c6h+Czr8$x_`U36bg5zsATM92WNV@x^ zC*wd%%shzVr@Md<q=O6ya1MpEI^4FhNt~BMhf1VY$nY=LylGw<4t*OTs*o2|Dl4e$ zUtJctz~%4Qbi&{H=e7U*s>E?L&47RT?r%JN<rONCJeOr77D3GCg20ap+)U+%_oOS3 zV2Vxy2an1MvL(quOqNoZC0Pa-Hl?z1g^j!pCCVcm$FgoaRGJrK)(?w?ZXQp!q^B#P zsUOtwJdQXP0;3Y-S%L;FJCs0)hGJoi>#i&i^D2{+^F@8BL5>Y;eD<w_{d@P`HDly? z!uOVGFL3$0F`fAT`d2Ug%4&{gh#MD={^(aD{Gts@G@CH&#b2~Ny7q}J)n@@Dp1M81 z`&<!`P(|}3jg3T-Vk?T6Fp>a80GFgVg{CHAcF-0}xWUc@L~2J>9&pMS<roHlb{Ul4 zGGRVHKWd?!eM12;?`d2NaXyRd@>pPD;&OF*GmM0SQla8vf1)$noUg%n(DD=S{3w*g z+Z#`J7-KbL^uNM|EG7>Bc1`Dv-&y{AD-Sq2KK%1vZ?miSTae`#SdNeHfAPeKk?_UO z|6?X~F`dz(I=SF!949FQ6EO+-1cjN$q+x1^oO0lJ!X!yaJs@4jeyF6IISv3;o0b8D zUM&v*Ij8Fo6s)2^6^?XGkfW5E6{hwoM3|0936LO1x;=p%${nLr0a{|@rn-XUR`j?j zK10VVe(!WND&D_4c}Fl|v{H4RS;BS$gzWck10mq=mKg#+_M(R2Kb!tHn-W9}6}|QE zi{~$M=RYqi5CFPqz4<DKSQwxFYx<wel@UWtu{Q56tBIVkHOmDk?}jqVIK7-eD2FIf zHHjI3Jl%<lm>B!vqw?P?f>2D3uEYs7R|EhCw=n>e!~q~xD;UsOWHPz54KV=mhz`S! z<Ic}$J2bSjC)d+0mV*hiHkNWTO=OC3#C5ie*3F4L=`}QC{lGNdeD43tlJfRtdJT<t zD-U*aWK{m)3mgdi+XV-bDC0lo+2p_dr|ZAilqAOZ)A>8EFYSNYC_bk%1QLh}{G$;K z38bPYcj$zjv<=#s!g>j?h{I$m@SMY21tPJDpl}*X_?jlyiUp44n5GT|Q3~C!Z>SD0 zJDASuQ&%?=u~)#+P~s5@Ek*=}0NQoT38C40bbgYYU)otP4{Ll%5R+Tm&a>%RyisD< zGp(RJZSjHVEUOBPo+*nwQ&7#DqWp=>=XB2w`u98YCr`}ISKbYKU+r6e;hTG^<47Jv z005XG2r~5k=l?$`HIVw4?~kWm{(}{9Spngf{>94Cz0c2-I9{(n2p|AxBmZPdK>|>~ zJrg^S3eDLhcPmfvlh}pSATc8^)6$IAaEeIADHNh%Mx%&>83zehWqO@YqAzcA;eZ!J zZp;m|W!uV+88FT#CKYK}p9{vu#hJ4|#ZH9hQIt|CP6d+ElJH8wDu93r^&ET8xT@-x z^=!3#b}gv^FE1jxwxX$p@Dm|3v&+aKMM~Em7uNi9o@U&8d;Q75z03P=7{7Pqy#BiS z&btNks~j?<_z2^UgeC&aZ0?7R<2`)-=E6fD`nVs;g5OyB^=-;8`<<sV$Y>2bUB{M0 z0V)I8Om6iP2mzoTGLUnG)n>S#MVT_8MU$q=a|+E>7TAE|QzI^h9El<hB7vkrND8_a z=T^Ug<_!dd03x6;qk>E|?{3iL@ZF8sSaD8g9YrFLqbDrv+o8qRg&;Je>>Ovm=2`;% zXf>Q&IUm)Nqa~36%br*ZdDB=;47#K3%u}4-$iQ-Ub1K!f(AGXV0hyQIjEpmdQ#Z!7 zBUI|U`ExQrf53O%zIM=8jFvqpo~5@ve-}Nh@7jZw>I~NO0jF6&fvyO$3<K~JbH;m0 z^!<6{dnWhe54!xYVD$bRMc{j$48Ql7{1{9gD^ZFmqCQtS0Wd69Cg~|3&?z_K{`h~7 zqV&CQv5ZGTE3;iXjT}U9ECVJ>l&B_V5y4r;r5cW6hCvw%ND^V1gnS|fc0I?}DS*-I z__MU;N-W^Cp=h7J;}Y&XDoG_s0Ta=l98sDrx+KIIQ$~95c;KxA5CcMSoCC(sd5#XK z{hJV%j-R3PZeLjAEz+yYyT>{AMelyG0Oo-nD2!5a$K-RtBpLEr$>C{z=gr+MxofVp z(RNU7GVz)`5rCxr8S~8SkGd=GB+y^7M%>oCP99wQ+~I%NEFG%PT=eeXvz^>)&B(tt zU^&}vTpZp^z2=x(ZiBVK<f=PwfPe!A&qjkH|LO|r?VQS7$b~rxg8L=u?Q43w%7!d3 z)x?y7D3`LH0Ov9u6tF883=v~TcM@rqpCgdbQJjPlWU(y@dpg>?d9mrxhTpIV9|~id zrf5g+Qk0WV*dz$p<3jm#HDZmNosSrhr9#AH01<480iv=9Lj+(N&{WK&Bo<%-5zPPs zQG%$9qX2*)L*HWo0sulbr2v2+q|uLp{5^8;{DmjY5}kt2{@>E0!*zEAFZH+eg4E8G zz0O~@C$eW{&(3N4V=}ox91V)D43RR0{BRsg90zPvK`BFp9G&<K<mn(F5znRbH*_3x zAR0?8gC^nKXeZi#cNrizPdGL8lQf_aiH0SG3rf`se`Q2M%dBrpr@!*nrU1zNZfhH` zQs&j13&~Rc{{Q(ufb8m9znqD!jHSin_6?Nd4hsdN9)jikMI>n@RE~U}k}Ao&xIjHT zt*yw+<+Y-CeC4`N4Rbx_`d}6Dy@Ggkm{wQB%@ZLxV49`L8_$hSL-6MDvy-tV9)#%2 z{K}Uus(KdxVS6LI|MN$CUXTd)!|Bd>`5WWhpzChmMWvZonV)Yqyv_v}y{U=an5S8O zXXPI0R_wPrzcsqI8D0n?k$OB=Ngm&brltPT7Y2^)mwS0HY9_I7U*p<7fBQ+;njncz zUwdh5_|>BFm9g~5<f6Z5Xpgzwcj8^`n=kwuyg8L>F&>EW*+oNc`^vk`g89vQqo0_P z{!qBToi)DpiOFt?jwnA%)CP$iqe^U3Eq3Jy*SsiYsA$Szogf1b{f0|Imi5!Iteh<g zPDRUkl<S6!?`vVr9LD~<f|#6`7s5$#=e?WKaA)%uq9_}d&{axVX<l^I`<rE$S}^oA z{FhRSKJQLNPblL52b4W8;%Pvo2!;7r7He6Q#7V+~I70!R0R~{0@;IZ%6E>pWeTBuy za9V;63}1e7oDmuIRZT}(oC6G$#E{ZMmG<x;%%fR4TYq*cOC%KT94`a<<=)+21Zd)D zirP&tSA~~+py}DA5;ylIbz=!I0HN_J5T#**;}qbnl^>KQpE>Yb-}n{vyr{14`MU7Z z6RBc&dv}`Vs2kQW`-sZxPgV-IIxFRj-rb&4NJHD*E3rava9`Br>+;k@j#5|yclOnZ z(n@cQEI+r?txMJ{tFQaT?iWmY^DR93Ird$&z3iw<*2=eT+zJI#M6@>INV-H_<wi9_ zQ7vcp#eW<B3iF6K{pT5h{L+cNqpV2p0AV$ejQVtWxb}3VB)hlZ{tMP4bqyrz&0W#I zChk$sMHjlQlmufV9ZNTxY#GEWLdr$?O27DPNAxPMQ2G8^UK0f00S$^*3fWG+H7d<> z6$OZa%^?;8bF812gKI~Hkdru*RmaWAQu&=?u4D~3Z<20#^YVJBeD39!VTfX!&Fr~B zdcxqJJ;r(MW!w+CvUzO$!tboU7hy}B_>8TSL`r>4z{NM`YH)CiK3k<lax{7`zbC)- z8<Sd=g#mn$?nn~P0X7j}7FtlSMFM<JB^IFwj#wsCAYwR*g#pO|{BSuppE=V!Z*}*- zGm3eds9sPiuv4E6f~b^-B+NuZ_h@9#nC{recQhka$D$}6HjpE0W1Tg+vuXj^X52*3 z=(o7UXOQM$c~liV3CwT2wUb48SH|Rwsp7uDHgrSCm*}8R=T#zSQfpYUcecztB(>}+ zGaO%8$sL(DOY(lr4CZU{JkpCTNV3~gPWL|3*`a%ni`UhNSJ(3vU#qIBTj8QwE@7{D z8N43PTbsov+Imycxza7E#*6)$kxr@&=8%4Y?I6B9%&PoUx_aHJNZRpduB_-BTfDZ7 zoAh&!TeHtdLT!YezmYLlHvz0}hlQ<zQ(n6wfm*_<E3*=m3km0@R3(r%Li?&}axc2o z+)!U)meioaQHlpEX0RKWS2R1np0sN+we)UbZ+BV_)}NMpD<kn7UG~zkYSZTlCHPIi z&h0ESESQpzKCT^3^u}})Lu~e(3zGz|y}AEj_+anVKfHT(@TcGQkB*Mw(^$Lu8?Wr# zs1=muD=&WTKihe4r3r3%6|u}{Y(&xRR3uyf+HAZuS#G&F+WAMncM3BA;q`y&j;&s$ zQyj!nG~UUFL?%863mttFx(LB)!q6ELg~F8P0YZcj$S_ec8j)b+dPA8uO1z(>TL!Gq z4hTa?$tp@*k1!+xodQ(Ez8+h%=#v-%eA)rGC{Pb%fg**8m_0KFzyuF(AfSHdhL-T> z*)33mMuTS#D>j?oHh6a}8#R$)_^jX+0iT?|2(W$kSAmEFR;2N?!4fAlD{M9qbe>A+ zc)f^9vR|lQp6qHTrH0U)T~+UVJ6EaSyu4N_QZgLBdy^}C_0vUYo282LI(~k(Z*6?; zGT96WolJyl(z2ypnU5~Zl|9LR#W`G?zG6(mHEf1(BCYh!e~mgm_-rH(Rm}=`yTI;8 z+lo4QsbL@L&2miVY?+HE*GK^xCAQn*yVfexBdd{B%Eg!bwsp~}I}96TG*lVspjzzY zbvuaMkQpcNG%6lNE031TW0&PXHpeO_AWG|}BFyNNs%3Nu;2@k8lgdeBTzFjQLr7Wu z&N$_n+Us^azI>xtV@Ad(f9(5rL$=PyMEKO%MAvU#_uSwbpF=$T!`Xz=b~kU#@+xLQ zB&!t4J$+k;tRh=X9NhkloTSXkZ~gbd%*mlCn4<@rlHTEbAxrU?V@EcRb!-rB3^@;k z2(m7qQwSm|ixMhK557f8a)0{vBy4J7VTt2UP0wOwo)_uZCm<ApP>{?F2SGe+RoB;6 z4M$E_PZ~uC!sCJ^fXI{lnw99baAUtgos6r6Wa#^=U~*>X*leZ-I!-K^7a~80OCw%1 zcQ0vvOo$TAQCb6&N16u5=S>R4p<jWvEl`aC$GMXN3okC$d3wr2P0=Y=Z0o9JUFW5N z8f|)pTx1f><z{)t>?~<%S8j@UT37i#+)($un^asfEYx5o;j+XYR0=|$N($M2d5K9- zefL7li9qs)>pZrb*ooIOp9;+yGafFn=_D#kzDdcwh}-D#^TK^OkZdY)V#N3YGBYK` zCO!38==EY?TGf$(s52@Z3Wk%%zBbJiv796|%`D`A1M1<BW2(W9Te0i7JSeb9D4QNe z=)vGL*w6mR+S~u*{{EBRi78ml)s|kJ6s4R}USHEJx(cP%bE|Asj4G{C<@wuJKKF^` z)$Oar9LG}cd*6BQGqo*On$3s>zJ)R`?pBt*KHU&~+9?y35nuWI&;8nOZcjNPv}wvZ zY@gj;Y1~diL_~}QL@XiZ2#KU1u=DYr#Bzc&QRq0M5O7SU8%`^`o;I3H^37#z1VbOl zIgJKv>;m8G%}Ink;*+~+OiANU6(kKLF-1#7U&KV7bhZGQ^$?@jfi{ulCrxJj>9<!? zcN=1k%%(-&pInv{1l!Kz001BWNkl<Z;7r{jwkHBj`8bmq;Hci#Kuv;YOgASdv4;CJ zD}eB#EJAOd(n`$xN`0@6IzBEWMo4c4k>oWpM~e=wQ<?37a%M<kX(%Tx+%MT`uLNNx zGsCW`F`dDt_C#G%&gE>=PY|?eH5>yii6>mnoi$ltrXn3WrOJ8PV+X1cA<=@8ZuHCQ ziF(<3WY7{ad?&HwbP~{)0muy%1rMo)+8Z|9j7U>}RKvkiNI`?C(KR_Ns+yaxd7)aC zL!fY8sF+q^Ot`!?LaA}cO_IxgemI&~uXU%#vwru%qbJ9Y+81AP=G~N`w=Zp8xqf}U z6tD1Qz5*XWG1f&Yo>@Yx`f|CohF|%IpMUQ9)=Dw2^WSUz{D}7*>e|cKj;osI(sAMv zqzZNVbx$<uX{IGXIyX^?q$wWEQNo-31M4}|Xdwx*2+)y>sL<0hASQ{Rr-^t}fwAf_ z38rB*f##`Gczk==E<8OQ`((p4mNY-(1B#O3xkG!7!E>pN*fGI#uzI87kRf8RW$83K zmFSQ(+8Uer;pGl(oZ3-|xe%>7_DJGa!jw;zEm|2D;)^9X=bQ$yI(=D+$Gs|@PN_Tt zrwW#=sRC+-#P}P>3d?Cvb$awRwF510py$RiE$y6YcoOAAFCZe5J1K{SK^{(yxpKA_ zECDA`Xyrl{N@S*$LQ|!ReGx@-HfPH+70vQ;Y-71H740=L57gDOl0EWpYzJm63a&I3 zMn!2(<t+goP7Ah|ug|!04P&)P*^Dd+JVkRdy|xswIVc8*@ENTaf}kq&6n;+YIp7H} z;w0O2F63T<&E9cibUGP!yC=z{olGa*?BcG!_xQEZpwsEbb8kwWg4q~5hBBWv6i>c! zt)dn@Hc!T#%lrn;NIrs^yH(A&mp4-BnOgn%Pw#A&Dw4ES!We&l`{&1YVP9DCTRDk@ z6psi6@+7`J`SZ~@5%O7#rVfQoGlec3M^AurfBM{2|8~xGQ<4UJnz<&P3KH=h8PgVx z`cVM^#%FvEkT8~JrTzXLehJN?beygQvnU+^j!0A#rf>>^grhh^+FYyMX<7LKIn-bE zmvCZpG{rJhk7cMpNz*!tv`O~3xP%7B51$9=6K99A{VbLOS{xTEM-|{V1~i+l22p{* z?ILg5`%6sH17+w8WR)_7fT-zZ717B>gDU#GI6LBM6fhWuqub@AIOEB9oa4bX=PgZ* zqFb5d=((@(@KKhd&fPqnc4|2G6006CGp)E+35N6H#LTp4LSw=3gFqjoI&H)gEkB50 za|q3NAUVCNY)^N{ERsrcs$n99s7&Il2yM(Nf}a%iBep1v*c{d8gv5v)W3I$TJP1>F z&)PE|55}|kaQt9Ay)Z7%yR+%s8Xfd6dWq`$c`eL0Tr=R1E;m-R%5!UCK`+TbZV@gj z)Jt55^En=InjQmg`5&z}wmGi3zH)8-N=e?mnU^bNnv?nO?==5)8h=;>DPRBx3FX=X zFcHW9kE1^y=cEqGKtN#RNPfU|9Y(-LFL>E5*Np(F7Qw!ag-aYod;&C#0tO|^{fL5m zno>zJbz;5}kJIV3$oBw>qKSJ^(rGY|Y@j*N^@EfxxA>!a832%PZ4toeuLuB?#3!Yd zbX*7+Q0M~NE~qoWZ|CEbTHg8=0qKxoGsT-O1I6p;7?k`e4RS*Xa;4BF#dSBwY?_xl zlsGH!APjLF^|E!KAJ{U=4-!!&lYEgGr9fzh8?in>k}~fKBpx6+o(jRr-KE)vbA0QW z;Ux%n&q9yJ=dIW~-9Y10m{ZK-^(Anj)n4}m?QxOLdqkJdg%Y2&xQtA!m;H9OEi7?H z8J%3QMkQPg2{EO@$kCMhyllDrklxdcQal4Tt&ZDlVD`7@px+rMt~8I}A(>MuF*v{a zu9|6iE60p<I*X&LV_pFkCDwy6T`vR!4$0CK<1O4SR)&TOJ4BF^DOAc_DL2=|k*o(< zo&^Aa5HbL){J2i=->fT4y+DP{DgrdJeHw8=K&9$U`%%b8yp{mO#^T{c6#4SRiT~vC z=iuxYR2OMn%&>9DrG11YF=A0bMTEE1bgprPf(Y9a-?hvLbA<*McF(ZIZ66NlD1{C& zO@X65ZL79)H7~GY53&$iDzrGs=BN~*p`aPPVUok?e$!)wFj0LWH&lV343<!54f&eu z`x+ajClvt%Q)3N!871M^GFd?kX$k9>OK#pV@<E+tGS&(5C>h!!qJtr%(-_csLSwtO z7x3&XGC6+?w2h-g&Cq$KjlDj_46B?t$}Ghb41WDc52Z=U$9{grkDV6g26QcP=>|Vm zia{cC`B4>wY)T77AJbvvYx00oRJ$gQSYGS85^XcFE5OSrPn|LYX+D&!u5%Fjm)|zX z!8gP9(HqX*<KWB@IiWX6&Nau)3(b6fOVkQVODzatrS6M*GQ?@fDh!K<c5V_;t}7+1 zfcZR2=M-h5g5~AaTD?}&Yr0&?agvMy0DxeMz@LJQ_p3?a^7h>1ftX<)*clbsJP`k} z`lYTfV~)qH)1CPgW!c6<*L{9{|MSZcMH5Q|*pm5>iK!%$yd+9JZHgzki-1aKkEcY6 zcKRs~-2geWc@*D%u{zmNX*$euDqI&0dA%B3MqJ`^p1H#X0VIR%)D@Ad;j#!o>sy-1 zn}Z9c1Y-LP6)*~?MUW>XZW{YbUS({`PN8Be6pVa2NhCWDpaTS$ktgdwX$-8*{81;^ zwB|9r(X%qJGrE7Bqb{&SjqC<5#Yrq(I}t-=7OZjxZn8N&$%&&fR>;b7IBu^}oVUTd z<M<j<Vq8}*3Y&!#Gp#80bNJyJ1!qmAGtgLnP*HM|w4in`)^X`@Q|Ee44)qdjb(yTa z6nODcXd>Y_Og(xOo=p8-)D<aE7<5|~kGAbmb*UXPu(8VeTsecwPIMi`Vyg0WU(n*f zu+=nn>8VwXQXoV$hjl>lnjn>E8|gtRW-0yMY475XoHc>~!2e`t>{9>awPMO9d>jY} zCMg41LeKP<?f&^JLvcF;qo8ymO`klvh;FTrXFkmm$YddtLPz!(>?b@AGlroe5UDB6 z^o5j*d6XuxHDkg9KG%4yeG)Zq{+zINHNU%350=(8T6)Yc0~mSQ#MkO`o|~nq$K<k^ zk1-1@h8>h-NoNPS%bZAN=7gxUaX!uqzS8!pc#49mkMOx78#?Ik&MHZ_HVu>+EgRAR z_Uu?18lY(Q%(8NJpBKa=g+MP%mt$2>bY~`3nXwg=k>r<+LM~G?I^iYS$u#8Apulxj zJ&B}LOc8+vb{?2srbkwRkK9OP9Bbaxyfj_2D1moCMh#7RI_@gr$$1<F5993O=)#OU z?$hZ7JoV@2mx(zEqe%~p;k;{eQ8Y8=gA$X;KHXrqa<z?Gj$5zE4Th?46iz6KOMyUJ zNk6E}Xf>aRf`;=jcfHK!S8KUkzLeLCGM8hpz!RDQkRtGJ8ZrLk)#S#R`dpMo0SqXL zO(F`JL`{-^aP7~&?#*k%CoPYi?6s|njJvAJFI;CG8WUSZQOJUjjYq74vlMX1V;C03 zqURL=l^~ip5zb8BrUqwEE1Nr5&uDQVRE2^vX#x^LQ>7>mxB?HBAaLuZu}$J6NH+i= zXvU-3g%mi~c%62$&3=Y$F<~*77QXXRgu<hhG>i#_sbEyBW8F=WsLj$$lFkDt><-IB z&w8o57S$q*gfc*IT&FXxTNJc;f^JT1KBYTl0R2A80y>bCi5CNVs<CWz_#z-iS~UG! z8M(uJE$kL*@Z3V|7^lf;dDSLiyI>^=nvS#0XrIE<&Jx95jJD5|*PYy%$<L=1+u)3T zDdI+&K?O1*Q1D2Ia-)zGi*bFrnuto8Rbad=jwBeem4vViw7a%awmKDGVdAPp1ejx4 zUaj#ctD}?xKmY*H?-LC812fZq`wI!oOF}o$RFD{sFab^MoU!EC;P!I!8xJ4Z=>>0Q z=de^4gpFsmmqUyl;?WUJBAIZMthi?l%(w|p^A-qm^zxKI5;9~q@-uamjxIBsZamkt zJs>gEyfWrXmxr6_c;o3UE}IrK9st%GFdQ4FV5o@G1P9hwt6|`fu!8vB2CeCC#?Qnn zGkMHnu}+6fy$G;HHHB>HR8xR3#A`qt42lfOO^TAm@nI<$#JFi>j`-rx5g|XQ5Z|?B zfq1seSq>8Np;bt6cN3&0tYO8lsnugs&nlBiE;me7l#Pm1GL;$7RUyr_VZPg561yj0 zR;3@WT&%seX{Doc>_k3t&&q|d%VZNgwmB_kuQ0KM?d{TjsBtAF%T&t<8{!x*(HWYX zGG<eaq!|;Zm`#8;mR#IKSUUA7dA;J5IaEV&IRQAKApk%G0D<>k2k^lyMxdHs_ktkt zd6qDoAITw;hrossQE%@y3vrORquEBjvG&5}?tDUaQj{h*&4Ls_2ofsm3vwv~0LN79 zJAxy_8N*_f(pi9H-VRfq4pwW=vLlaGfs3Tma!!4+-UUmhQ*dVnRV2`zPM0uBe0H`Z zgr+*6`+OMzbH*35@dywN%~CxTxo)wyByb`%P)ZhSYN^QkY65u}l36qJM^OU?{y5KC zQ76xWNt$C^ClhlsKEMh}v~EotRJ_pRS=W;l4DH-hk%vXcOhYvtOf;36rj%Gb(usej zX3?X$We}2{A8?btJ8<avJf6j2&k3^o<BMJwciYamGYjoXDY|H`z?}*k*IPG>xkRq> zxX3xGIEuET4$pb4h}~Q>HB~Ft?QsldMmG1O$%s{mP?VKYrB%(zdSz?7q2()$iYUmC zq9FC*TmFr^4(ffaEq=zALpoDJU-gL#I4a1boo#1qPOZnS4NOzDlAFzw90U~9<IH7P zDg+V1D$9nL@`wcZh?DqC?W%zCQ%=d~2|_W>>Sa-yi}AQf;m)v_139iY+rmrxVb%hJ zNdZ7f;~>b+^*RRBsU<Y2X()urumwQ2dfA{rbT(aM0pBf2kV+9%41AoFhh#oqDh~A+ zlx<TL)=XD487>C%y6Y;0oZn89VR#4N$7^MfZ%0%<G$XWR#Q=qf2C?ZnHL9J&W#>ej zo4$38vZtFSZJ*l_bHe!Z8DAhiYdVKvR_<N~tIj#Y(lj#@;uDQk{pGYtPsTaD-e0+l zo0Smnbf$NBgWcRumP0G2?x|}B53h@R>m}b5-AAp;`5%>;vXnaNm4KlXHUSKW08M2S zf&c)n001EHeaA(?4|?s=4{(bAyTZReaO}c%q7-?^W&+jK113e7%?Qcm0x-M7(?u@@ zb2>%zj201se8i_g0fnimr6BQR!D2xI<4Ca}L(QasL<3K8QyS&!%SZ^q;SwBLIovq+ z2uMk?#sOY3(mJy3f(LSQ002l7fxs*)X}CTMg;6z(>Db-~V_?&(76gEtzYKvjuR$hq zJskpLUdC<!I|Vu)&|yAyM1dnHQiRq;)h_2d%hB4|w2acRP7&wfE`W|vK!Fcp{@!vW zU|!d-IQ`}>a)*1<s4{;Y<=ThEJbI_Z^z0H$C(Gk5-^+yK%sU0yvdHpK;uOqfjG+j9 zr=`aix^|?pv(X)b<><-y#v3Pmbg;30UlZ-q#>$ylz5Mcab<biBhdU*+utSxL0s~S? z%pj!Tk7{MW4<3&1UjrU9KVUll$L(L*c+?IJKkz(>5Hgc7VP%7JwKTF39Y=+jm4-3^ zX-cFpm9jJ<NkU^nXo4dO@CaiFlGx4&=4}=YRY+850w>F#uRUK>RX1ddv}fiWxqiPa zSQq0R0E5RUF9IszQ^<`l1IPTl3_%1NG=LVMNse{p8PKyyc@A4PB|9Ev>o8^B-C@!` zX(880tL!CZd39oSNC_HQoX8<gWlVbYahW@_t7@DQYWXdNj*6~P^g4#Z&rVE84-@uX zFN_uU?6IAV;@Jb9AH0?r=6B#_`y%w;b=-_X(1&G4EtEfbv#RT#Rr$5;R&G*UQn6HO z2w}#s&fCk?o>_R@S(<hk?XFYmzYWNFte#{$1&@z)?aI~d_2x!%>*m$<<$}Ii)kTiu zIYd$DeZ7ni>k3)~e{egOBsE_7{eSu3^y=-%-%ZjC+1O>oOrNquMj;L7VcwQJh00<& zRiRA;obWioRKO%j5+|WTNW=_tBAZ4;;G%p49g&ee#vi%$CM2PWt^$DZI1}jHB>&{v z<+ndY{HT-%fC(&~1;~=p&I^4@0r9cW1b`=JH7E^hqYVIEFDwH#1s#3m>zf^+utz{# zOU6}DPcvYy(-b60GHk_ZzpxBE{dkp$l2er?l&J##@^DG1jnUbbbl+C9$rrbHa++3^ z`|@&vzC0GE4cR{JrUz#lo({B~wXRMv%ax6|s`;4i@px>Nb5>L<A3WOC&-zPT)9nkw zNu3c(@uOTtJ^Hlr+U)B1tM&A&pWYBkH!YPLQPLe%A_*V}U<82(f&3#{pdbFl)dc?F z0ln1!3?R-w_bGdCd|4^aE~?Bta??P77Rx9Z&Il!Vw9m8Q954U?-bXBllSo7<&5(d6 z0)=QJks(jwNuD+#>mO-KI?7V3n26iGDsp@`)j%+bH4%uh6KoXj?~n;#6A>hnBT<E5 zSey!aW@xD`(S!rmgM5Vw@3CASL`QK&0tR9S6@IFjRT#(epg=h|64fCcm$`!?oR%N0 z%z@~r%tXL|r>h5woEYgz#W^_VlVCa`^xPO4h1_Ail#O!H{A|>7-+CR<emlF61%9P2 zWH+v_w{|?Pw6dWzWiFI0F}A3JGMBjNnYCLTB%5@CVM+LUw*C5a`E<5)(P<<P`tmt` z9)PRQT)(zb$`w_ym{(K|A&hB^5d`3U+k-wzEAM~!iX`f{ApsEAe&JJ>54w9X(>J12 z5yHe)65F-|NO4pOZD}Ggb9ydD9t&+6+B8ku0ia1ZW+;GTD#I`xZeF?=+pHTEBfyPB zyw|#Q$qU1}@?;IfPyH1d7`@I7o;lX(vzyeo0LD&`SD+W=rzM(lv}u1k31wi+RULVu zy^cW~WJm*piP2Et>EU$-(%7+Uv1Q5%JhAE=>=&^hix<9DH5rS_Ay@JS%MSr>__jZh zSvfgKx*2KT9p_K%hp2rrj}g$LqqcZeyLF|xqH1cnWW$vedQe1#^OYJ=S<Cc=AY=Hu zei41y)BH|`(Y!5XR&u~Z554WPCu?tfRgP71?dLaMx?SD5R;!8>g&>3wBKZF72O;>F z+%-o1Hh_>oh*y4bXYb&N`@-N=x@-$1;TaUBag=a#F_lFpP9PVCB1q|o%2JM=AdY9~ zbV~WYCa`|W%ym9dy`Tso=8_EbLU{L1RUL`*Iu&Aml*54zE<0CC5Dqmjry_vpGxpkv z$g?3XU<iC-t}<~V*wk9LfCsChBd2q$bsJ#HUsfg1i;7~_9tYdVANiUHI>iD4Q?*w} zXJg!=Px><vPWo-+$DudDRN`{VI05aQC#-<J&34;@mQSeb`CzHQZ3TM$h_1Vr%QT?! zj4_Y&dK=aCDXC7|7Ai2zzzo-(m{t8~BFg9Y0(|cq6fs}7n8JZ@vqWG2%qO0`x?86i znt})+_>o8(KQ$)ri45VdeIY#@%$Gm)@DGv1rw9iWg@g#WmP`@gVgs{KphJK$P0?c} z5ktZz=MJ6EA}f(>k~5_gQFAIwfojhHgQMFrJfEzvAPnh#5gID&KYyHK;CRk10T`#O zCu<?gBk$od1ZlUR(;ygufR|kcP0Q?IJsQ?1Uy9<T7$~v7`}$`5S}Q99i!Kl`oaItN z+CxQzBaeoj?Qq#NDGiPh>zY0RVaWMIFAlz_ikxxfr5i%^4k?)9-Fz1+DR;WQQ8D$k zfmu)iS_TrSPP*lSeB57SW<<US+3_KzCQlT$|7L?rk2jl{c<0WwJ2#&B>`tj(Dg6KK z-C2xe*LfJ=bMC&cwO4gj@5}6*#pXJuOi7d>BM$Tszzz@~ddWi!I7pD^1ju6$Aj!Mo z06_pFa7-tHD2t{zBoz%gLk?%@>FMdMy1VwhZdKiVJ9!w2k`=K@$r6V%yyu~x8~1$o z|Nrx!b5E%Z1uzI9d<tlw&-LHq|NfIj5D>n$w6{HQzy8v}HI|b@n)YlGM*>Ao`7yzd z*qEYFI~4J0jJQw$hzRWtpDqS5@=(Bd2G&X28Im(BAq+Th7(YV=AM#5OKyMPylYPz} z=far^$<ee7;*l4^0sxTc<`F}JsEHF4K+WxJ7NE#d7}&|o!Ym9416NH?ALAKAGR^fd z9X18r5RSv*me&J)K)5Dhk3p0h+8F4wM-<<iwyC4LGp6I=3m0?gQ>VC^S(5a?rAU?u z1P@Zh`5eRF4ylxb`|Wr!-5PVL<vj{NyMUV0x;1By2>oC;)4!i9<W^t$otHP3pSz%w z3P%D2pSqwC;J*qJ1R=uKzlGiJD&5oT_qr@?A>73x++hNJNcl6t$ATDzA&m$sbV&>l zB=QZ{gAUCA+KzoGI~F4xN4`xGQQxTJXQ*Myo~6-rcXkOw1~n;(bSsPcr;TAI9*k2O z25>OtXweXmAIjTBIOBO%gvh{C80vY|b;6rQE-Q3#?U0v-@o<rtMdoC2*7uqPyc2EZ zDy=SnnB5`}I_}Tf5i?}A`khHO6LKqGt9X&J<ouXtmU+@50kTU=^<y`6f0Jjd8Y);5 zNgT%%*S8nwx1BZk6IV2MKkBgkez`kdPp_W6dhz_~*#b#W@F~>sv(KMDPsjHW69fT7 z06-*OEjO;U+|qZL4>}^z7bpx+7#2isS1?IFh#@I~o<okv$PW=7kk$jf;6gM7A(#PD z^5r3gYmu!vu@jQ()PALSZ!v7Zs6aTK1EqkYL2l&g!q&3y5L1o@Xof2gYot(1r-nQ; zA=HS*@wULi@bJ#*407r<rG$>aI4qHS$DSPHQ!@wr*4w&1p6-indsJ`LClLDP#BvRh z3v=}H<+9>`Q$O^0Jhe08^V<KM3$^NA$re2!WJWb-_1MP>*f2!GjOMWk5~lmb)CXvG z`lI2rU3w;U<#hS%xl}&KVxOE3eX2hY{`^S?A^;J95F)tps{j6Gm%MOc>mJO~E{}!` z9r*j4U~|Z@A&N0hBx4An#YPAbp{NJ=*n*g-%{(HGJQ@R&CNXos1L=(EWQnFi%+`>R zYGV~&q`-kboFn3H&K_<U<1`pfhbn>kX>VA>$bfJKYRcdS*HCe5nAXJDngBtyhQs$) zd1CwSDa>m)P6XTiV|oa;3>s|yWQy6CMGX*5>Fdf$?#yh4=9QyHUN;YMk+laCy|^V~ z^xOpZH)0>8tq0th>1ZaZPEm<02al=~CNeKS-(6Zdw|3#`+48(D5}&j=lACpYA?W}J zAwU2k`i0+VZ5`AL&#Uj%Fgm7Xi|A63%m5IP3`rn^rXBzd=R|_Q2zuD#RYpO$W9iNL zq2v$&h8%<7CuobCpOh&y4(Kc$nyEHd9+8|q7}FUL&!mtQyhw@aMT=(85i`{oY_`j= zGGTD`Ks6}7sVv@4gPtx|<tSLO1$p3(&9YISYqa$q$zemo&eWYks-%}z&#KbpXNdk{ z<-t)dGkce>L?7_WSl>})c{{LSYC99tvyLeEy(wK5O!9(qY;vU0BfNXmb6Iun%xQsJ z%`&1yJ&|pWFN_I-5JHGx3?W3Y@J*(*L!vWt?|g)-d>ls#3b8I5lAJ@L$ijj^p#3<6 zJ{w_9O%go96M!RF%8cefC}U)daMzu1P}5*sZ#iWU8Xlh_fhmkR&P~Jc02flgkHsl1 zb3@r5gdmUL^x<MUf<rVdGVz#l^tg{%c9tGMhZP2%*1+(?FghX+TZB6_72YUHOX<SP zFR^Xq9Cuhti`x$}IS)uh*Z?VEg7SsYk-`Yx;hehKZu2zd5Gvd6#d_gzl35$2bXL&U zt}JnCR;ICp$LC=3@%;rKpNfNkx>9K!!?1Lv{eLZ1l>uS0sK-zO1!K~om@srH5=<xp z3o)n3kX(e*fRV{C!0~_|hIHU?3><B&^fVlF%XS`GLp;NQxF!KT)lkS_%PBCx^&p|* zsFv+9i`6s%jL9+#W8+X!(d>wl2==J&sVuN20q%99@YwhLrWd#*rOgF__VOaZ7BBRs zesQ`htK{xvW&TdIoE?p#`Pn<<DSXRUBnuDuxrqZax!R0fr$;q^B^EF!+r{~{yq3<Y z6oDt%<?}H?AD@`~xHx&#U!HrBX|!6IZwYT5MjYSg0T3{5h9exrGH$34V<Aa#zDt@c zsd47Sw`j`tIhV2IF+-X?W;)whUmx>~2^L&O><bx-gZ?(Hv!FwYAuE_EFdbMj13V+u z%hRJ+8jajo!C`cy$kEo_ln?~9VK8U<y+aTV_eX)_X>*vbUMw($^Q*!Uxt8*-EBV$( zi}TSgUQ8X?i<w=dNWEK>;MehDbc8f!Fva*vZfM8z@o^yLT)S{KozjZy%G2Hs`;9aC z=l^xPK6F^>QEM4IiYN%Or|0agJ^q!A#v9vNk)3H669gc{+!;xZcuJg+E}{cEW@uX# zAV!D2ABZLa5fjs_F(TZ~gWt`KBDtF-P&;Mna7ZvsV^XBR1eXUnwwHrr$Bkvo2t!^4 zXsQioS&L$9$24_&TlbkxH4;N=&>b7j$Q9D8mAy2V4dB^Dv}@*bvs-i_HM01UxdWGt z&2T|z440KQ!$R_a%W}KqGN@v*fOVjhquPzAP?>1r{F(%Dg2a+bAAe;gEB~qgS8u)Z z-i^JU1%*de9v#KiSMlpRndi>qHxHn~siqfrfkpy?2z?cqame8gq9GO;p+e#WMpKUL zMi>P|PjDlKsgAxb4aYT2g;6tRRcr*sqa`@ju_+2?0frEH?<p8i<V+FkGTUShRtR7n zl|wQWRJ3&ZFaQ7$R!KxbR6Wyy1A5fF&_LE^pu~-%jy<<#_m^KRGPPn#l*S!CP3+qR z#qR?-L*3%nhWn}0{Vj1BY>!vDomeYP9#ZR6x3QkJXvGaib$80<(z$FY#gjkX#ZQ#{ zs!Z_lcUXDUJ$&!ZkKg?9-e^K{@Z+Pn<Z^kiOXV)k54QGGt5}3j9X1Xfge#t8(kyfk z5ztWLX8{KNF|CZ{h_X04u(8OY;#-v~^ij5z7Lj?-S)<^HWN|I?paiS*-P1HOT-?pb zW0D>gZC8V%S;pt%Q9a7w?l9=!%+R7|(7;QpIWKpyMD((H_IMl5vU+_py+m&w7t7oZ zuWI8hcHO<lUhHq;Dt@oNK@Oo@hIMj7s~alp`_o*O%xal=l>#_KFah%GGND)g^cyd% zpU!$1KYF<HqaVEc{X4ZzXRvQk3@l!8zi*aKZS-n4tujReL@^+AS!^OXY**%}mJ`td z6U82+$E42$3~nKl^fA9zHF_^Cn8Q`2or{_x?WDnIXI5gcYOyz6Hh_i(HCd&B0}WkP zb|`vida0@Fj#Z*R==E!S>{gj8XC&kFMoE}C#Ui&&$@=tKUT2u2<GCV!$IcZ%d&11& zw|CCd?=lzTLpQIs><!f(6w}-RmWM4TTTJH)nK?~lA7z<@$gj`jpWgoZ`An%&luM^m zyvEV+{c82_wb%dm>u+3pYwPyCy&f+YSMmCTO?QEf2?Qa=d61aV!XP5>s3%d*kd`Q) zVgjcx*aQ($3dDvT>N!T)tFY{GAs#Gg6B2~-fhvJry|yX|w^z}uMw9_?JDyI%b`<&4 z7P~+Cpy?Rx#v$x*=gy@zg)15Rc2p4kcV?w@_j_7J7urz4w9XB6UB1;`ROssFe3lzJ z>)JtgQ9DeRQ|580)P9IGv(KfLDp^h7NelxBCPMz9O#Y_v-|16~ps$?KE5+>TMX|V2 zau+yUvd4SY@lVE<?+9}X?3jA5x?HqfEaFkj_>gjk6;iea439!Nj@y8foODQ<L^?ne zebl~mre0@C7<N!VxJ>zG<Se65#%p|DgG5^-+eLSj0XxhB6;LLP4@%L^mbEuB4jcT{ zmy55dFBFt_d#5ttL#S#(uclUn9aJf##?^{gZoQM<$UMAX5VPaADhu-Qy?oX&)ylZ1 zaMSB4Yl@eObDAu2L{f?T#+dxaEB=3}uF3?>D%ttfmGw1tBc+g~3BEYD@54YCJ$y*u zsdeE2FmK4KROm#+gktUHB0q}(^1CI?ZiW;W$%8;LSqpNMZDGDx0**^&aKGCbE<qUi zF0L{)1v3s;DFFRYHdp+CO7<Ozh`pL<P~Jxa^Z1(O+u#19)B0TMEoUvQy>6B(+=rub zhTnrFnctjLSbERQaSPU)+_FL)h*{b@PP39+r<d_gKpZw*A)Q)S$a6GHCPCyk+c)7> zJDopogB*cj1kJE&d1-lNMT#eWzik<2?9GNZv3YW-&<;jNodQjYEE$b)+G3Cz&{Ej$ zcVbEAW7<>4GGw6ViIN$RzLTr;a@c6Lyj3Al?QU)k4@bFLk*b#vu>3Gc&AN(jfht3c zcGMAn@OF2wW!5hJ*;NGx*nIBKx6c;JJNGLUcBh^e6yvy%k*a|p5;tH9E#ZF|ZY+|w zgq)a*7*WKGK1PgJH`W*DDl$!xFeycT(@g#y#2S@XcV0U@c4HRb>p22L@@nzo)pJ@w zXY!@22gdh%B3NDRcHo0rMYZeD_Z^-_^dXjIio%SL7%KEF0!GlLF%skH)?Py>%XEij z?wpdbQQE6F=mp@r;V7TKo8sbK->Lv}%tc{kY={vy!Q!{Z_xYRYfAwd}1V9jB*=OE* zaK2LNyq_wS?{3eNT6H_6m!^MPoXhOBbU2r9kwrqT&scphxEt{EguJ{YaWqBXShAk^ z?KJCQaQ~0~VCjP&-FPQTDcW%Foy|Qjj#X{$)N?PseEIV4|K1tCc9geHucID!@9&r; z+u%uoB!d_$xIKbP%NSvbEX|KF;Xoo0gHNjOwy%~k*ph2P4x<k$j=j>3Qj<}0O@f}O zJ9|W22)&p=7$Y6%POBphw;FloyZ_-89wGn%M3{Qz?YCCu7RfiG#f9c=jGdqLC@L#G zI67D8_EKDW+1|xt2Hlu)bNW(NNGT)%f4bq2jNb+m4EsT5b^hi2t-i7UkzG17kHGGa zzW>^_h7&_ohnZ;Y`R57+thu!4?8LKcw&ddB)Uq|ZXbpAbQP4*?PEIt7^*NJk3rge7 zM*gA<!5#7DB?d%$l}WS|#p#yQndd<>$~Pvb!dAiZV@S}hEs)Y-v-gquPyWTrA_mc; zt__0Rm3x0tT+RvG1MLd_x1LrIt`EzqdidaMX}nLuA#+&MSF;-nMNTHLM;n(3k>Bc` z4!nEwRh(M=LutPShnoX(X?2xZsVKd7_qX4x-rlB%IF+tUj-91S<3ncBkTPC|(>Z#k z;(`Ed0)!y6X^U{A$pohu$^5(h%quP?TCKDFaT%b19%mTfF<W_MNKvNC?CZktAu;sh z7+Fy2<3@jQ`0amliT}BYh5$0pv)^Mf1?BjzdT!;NgG)kri<X44yDuza+s&D}ekD&5 z6agSiHt)UwCJ@lVYs+&0(PzIY?e74ov2_s4Ei7h;Y>|{Co7{cl^@qoY@dX}}^Yvjo zo_%c%x0E(R&>(gKGB5z^;RwS6j975!qW4#?Q<uNdwb-MAR=%->cRC+o>%cP%H=B8w z2N()=GEp$I0zmn!-=1(s$KBGOzQ7{*_a_?R8|A;*ljJ3I8<Z}Nf5hn}X_pGoq*=_a ztu1R@QmB5RNe4l(d;MDwKt!#+TB&{LO`PDv`a~=g*SK_{R9x5?ri=SK@BhV{ckXX; z7sKA=GH)@EqGT+FI1dqNdA`kaF+ZVL!u&tq%RTdw0z5xjz({K*JMKm;4ad$jD_{ds z?)9#V#6;=u(hH-3>2>NiE%}dM;(iec01(0Kv*VkTv`{|i>p8965s|fx6YHm$k3WY= zgnVHGA^-r`{?C8#kAM0d`tc9;rZOY4_`E|hJjufl8{&Pg+8UYrt(nS?#ee$(JEKhm zGdQX$1Wu4n%!Z7U^Q1Al``1U{)mO?<yEl1`oo4SVfy2cI=xXa>QHNsBldo6)m;U^? zxcA@%@u+V_L;o9BQ=hV9iy#2)AA)D+z;07K&;9k}88)2*$*({@CMF1?AO7WcuYU4d zE8+XsHU(Ns@v2D$agNC$&yS2IXkge;diwsef08k2piX4lg8<H$3W6BsD$on3_iI&A zPc4v3@ljmYF15-xmCcn9+18ob$jDuYJCQwIJ-AWS`_&)^?16RWrHjM~^Nt`uxA(MD z#yH%_+7yu(d7RM%LGORu`Il=y_Y#jjxVGu#(?nk9O;H4f?1i*rJHj5J?Or}l8IwiG zOVu~lA?``@B&1^0@p=<1ieD6t^>%xM?iB3zud;?nULQBT%RK5;_~Ui4M&3O)iGHxo zRfjbVue^9s`>csa5Cn$>1`r@2@_1_#03aexDRCD2=_&(3cKPeii$=A2eB2+fl;qic zr)MI49Am?^C5OQAX^^(>@z3K~AvoS{Pa2)K8ACp^v72eQ^Iq4Us8Dcb%KHJ|d$&p} zep}F}16y&UL;kwckD`CnlV1Mrx0l$@?5IP4aGiRzf0q;^kGVGa*=g9HuM0r+C+~G; z=8{B`nkZngnFTZrScoLX9f(=O6!`-&8+sO&cA|dyfXkw{OK~MLI*z%L30uR((Zt#^ z*;vZ>vLx0;0%Y&Qo%x1F{%cPplko7wG5M4N5`e+|<4&WGb>_9=0G-o=5iK|{C)vkR z-oh#4;R*pr+LD}U*{q9tvoktX5rzkI0fB-$MuF@%S{8W<=Q`txII}zCMqs8cDk~h7 z7<r-waYDEU0KwGD0FCdr?hS^WX-ue03<ExJ{m$X~^dQ(cn%U4}_(N763!WWQc-A_K zDWK>?w`N=B<|(H9t{J7LV-n|pXQcCsCB4Fdq^th)F!>o3?JD?Av)X8O8y{c`8Jmsc zqxZ5j;}6>&I8+3obwIDW9WJoa>0mg+M<IpM4|;(uH|r)*T~IgshL|^%Q*-M%>f<<- z5P6!Id_sj<SO9Ln-R&5oHj3g0le$lI^}fa$)9g~zD6TWX#6xTdOAV^n_a=jO9NNL? zJ{G6Xn}c)Yp_Ic>gBhm_NwRsmYLj19sQ@$>O{S)GV757Tl$)nhJ~}VrGfi(10<z&~ zk_xc>Fmj{*P!{#L8ZzhKy_r6vPK3O}wiJ4%o!_7mC{HbupR<CZ$Z@>T9uU$oZ5v9Y zhmqJK<^w#O^*All!swWfDS=^eH;87P|EB6?PR@1+GKi};&J?c-QUc{^X7cx2TnNT4 z3Il+MI3^-QAq=4a5DWki;_&0kRZ#zEx-%|nvNB=#8Q&by`;h(G+2p}L<xGAxPYA|V zyFD0dYJk%`#Lc)GF({S3ki?Uxtc3lw0Y3VJi6nAkgCl^2DL5OiIGssK3^6Qu_Ft`N zf=;}NA3+e*Yf|4{b|)+)Mk5lVjtAWD6_RlB6-zn+hM%k`9v&JqLd_aO+)*+!*R{my z<x1k@tHtCq?u<kbL1(Y$T6Bf3JBnqcAz@q63rzC#lM?n9q(rS+F9Pvd{nnHUs;_v6 zx<Du4Bw_OZWgdIKZxN1KKo2pG=*jHL>7*f>F!?1=Zg;d^$P-KMR=~h6{%RrF+DVxF z5+|)4L!{z9v)bNUrv~=Q8k>ZZgvl>iIDhEPaN2Cp-jdZ1l*=ng+Icd@7ue;30KqFu z^dOopR1hj(P$Jt)(#~TYpF2i;fzjmSCC`WLS>E?U87Cc3StOIWdYmI5KHq!#1^3J$ z!hiP}t&K;TFr^D6-v=m}tH(I}FK09X03hgXwStO)3qGWBSv;Am$Cz~BmzD_t0MvH( z>4k{mCh*y0v6HNHKGm>u{~*rtzzK`XR5A$(lTVFx?SsC^$o}M6E(s@J{y{tzn*hPQ z-aF{9CbBq~I7y<(2?51wH=8QIl1rQ<(d2}H!Y>x&dN)*=L`f1&PK+pl-PuGJlAe9S z<TIc|_U?{;DV0D;n4Daq#I5bo;<}hXNtm1n1;vdw+Q0t{pFl~NoD9Xf(T*-{;0ct3 z$;o4Mt066)B2WIBBN<<9CJ2IX>wZspVV;D^5!r;vNl=^{El<2upb{tvlM|tg9(1O; z^E#a%Ntm3tI_}?ce0C$xCnZY4<U}a`gAaxa=L=Gjc@ickO0hO;jnaBqNuVT5P7W#i zn+JtwrBnhXVe;uWULXj-{zLlV;@NTnC5a{>#7~GQlh@yiuDrPXnKvFJ<15Pq002O| z^W&;he(6%u!%vdVi6si?+`Qgg{=-Y)NzXm#2|u9+(U<wz0s#6SeW+Ior->&VivMJP zKmgH~{4E4v?C$+A|Kj;4yM-U&36tM86z_Pi8NRqi;7_cRB<Xx1QarnT_x9pH<C0=B zVRB+biJhu2pDcJ1CZ8oGhRFv#36p==@;Mm^lVm*Z@&5qC2?7}9fU#Ks0000<MNUMn GLSTYr$$`)S literal 0 HcmV?d00001 diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index f6b4206..7aba37c 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -151,9 +151,11 @@ <div class="navbar-brand"> <a class="navbar-item" href="${url('home')}"> - ${base_meta.header_logo()} - <div id="global-header-title"> - ${base_meta.global_title()} + <div style="display: flex; gap: 0.3rem; align-items: center;"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> </div> </a> <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako index 4e62198..c65e68c 100644 --- a/src/wuttaweb/templates/base_meta.mako +++ b/src/wuttaweb/templates/base_meta.mako @@ -7,11 +7,15 @@ <%def name="extra_styles()"></%def> <%def name="favicon()"> - ## <link rel="icon" type="image/x-icon" href="${config.get('tailbone', 'favicon_url', default=request.static_url('wuttaweb:static/img/favicon.ico'))}" /> + <link rel="icon" type="image/x-icon" href="${config.get('wuttaweb.favicon_url', default=request.static_url('wuttaweb:static/img/favicon.ico'))}" /> </%def> <%def name="header_logo()"> - ## ${h.image(config.get('wuttaweb.header_image_url', default=request.static_url('wuttaweb:static/img/logo.png')), "Header Logo", style="height: 49px;")} + ${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")} +</%def> + +<%def name="full_logo()"> + ${h.image(config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")} </%def> <%def name="footer()"> diff --git a/src/wuttaweb/templates/home.mako b/src/wuttaweb/templates/home.mako index 61a4eb2..1bb5f0d 100644 --- a/src/wuttaweb/templates/home.mako +++ b/src/wuttaweb/templates/home.mako @@ -9,11 +9,9 @@ </%def> <%def name="page_content()"> - <div style="height: 100%; display: flex; align-items: center; justify-content: center;"> - <div class="logo"> - ## ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))} - <h1 class="is-size-1">Welcome to ${base_meta.app_title()}</h1> - </div> + <div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;"> + <div>${base_meta.full_logo()}</div> + <h1 class="is-size-1">Welcome to ${app.get_title()}</h1> </div> </%def> diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/login.mako index b50a863..6f07542 100644 --- a/src/wuttaweb/templates/login.mako +++ b/src/wuttaweb/templates/login.mako @@ -1,5 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> <%def name="title()">Login</%def> @@ -8,7 +9,8 @@ </%def> <%def name="page_content()"> - <div style="height: 100%; display: flex; align-items: center; justify-content: center;"> + <div style="height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;"> + <div>${base_meta.full_logo()}</div> <div class="card"> <div class="card-content"> ${form.render_vue_tag()} From a2ba88ca8f221f34774ca62459b9a4b9d20ef0df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 11:45:00 -0500 Subject: [PATCH 08/11] feat: add view to change current user password --- pyproject.toml | 1 + src/wuttaweb/app.py | 1 + src/wuttaweb/forms/base.py | 57 ++++++++++++++- .../templates/auth/change_password.mako | 7 ++ src/wuttaweb/templates/{ => auth}/login.mako | 0 src/wuttaweb/templates/base.mako | 1 + .../templates/deform/checked_password.pt | 13 ++++ src/wuttaweb/templates/form.mako | 6 ++ .../templates/forms/vue_template.mako | 2 +- src/wuttaweb/util.py | 2 +- src/wuttaweb/views/auth.py | 72 ++++++++++++++++++- tests/forms/test_base.py | 32 ++++++++- tests/views/test_auth.py | 72 +++++++++++++++++++ 13 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 src/wuttaweb/templates/auth/change_password.mako rename src/wuttaweb/templates/{ => auth}/login.mako (100%) create mode 100644 src/wuttaweb/templates/deform/checked_password.pt diff --git a/pyproject.toml b/pyproject.toml index ce72e00..52f460a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "pyramid_beaker", "pyramid_deform", "pyramid_mako", + "pyramid_tm", "waitress", "WebHelpers2", "WuttJamaican[db]>=0.7.0", diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index f8bfc3a..6aadc0c 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -122,6 +122,7 @@ def make_pyramid_config(settings): pyramid_config.include('pyramid_beaker') pyramid_config.include('pyramid_deform') pyramid_config.include('pyramid_mako') + pyramid_config.include('pyramid_tm') return pyramid_config diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index b8c4a40..0974a50 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -337,6 +337,16 @@ class Form: with label and containing a widget. Actual output will depend on the field attributes etc. + Typical output might look like: + + .. code-block:: html + + <b-field label="Foo" + horizontal + type="is-danger" + message="something went wrong!"> + <!-- widget element(s) --> + </b-field> """ dform = self.get_deform() field = dform[fieldname] @@ -354,8 +364,50 @@ class Form: 'label': label, } + # next we will build array of messages to display..some + # fields always show a "helptext" msg, and some may have + # validation errors.. + field_type = None + messages = [] + + # show errors if present + errors = self.get_field_errors(fieldname) + if errors: + field_type = 'is-danger' + messages.extend(errors) + + # ..okay now we can declare the field messages and type + if field_type: + attrs['type'] = field_type + if messages: + if len(messages) == 1: + msg = messages[0] + if msg.startswith('`') and msg.endswith('`'): + attrs[':message'] = msg + else: + attrs['message'] = msg + # TODO + # else: + # # nb. must pass an array as JSON string + # attrs[':message'] = '[{}]'.format(', '.join([ + # "'{}'".format(msg.replace("'", r"\'")) + # for msg in messages])) + return HTML.tag('b-field', c=[html], **attrs) + def get_field_errors(self, field): + """ + Return a list of error messages for the given field. + + Not useful unless a call to :meth:`validate()` failed. + """ + dform = self.get_deform() + if field in dform: + error = dform[field].errormsg + if error: + return [error] + return [] + def get_vue_field_value(self, field): """ This method returns a JSON string which will be assigned as @@ -400,7 +452,10 @@ class Form: the :attr:`validated` attribute. However if the data is not valid, ``False`` is returned, and - there will be no :attr:`validated` attribute. + there will be no :attr:`validated` attribute. In that case + you should inspect the form errors to learn/display what went + wrong for the user's sake. See also + :meth:`get_field_errors()`. :returns: Data dict, or ``False``. """ diff --git a/src/wuttaweb/templates/auth/change_password.mako b/src/wuttaweb/templates/auth/change_password.mako new file mode 100644 index 0000000..c64aceb --- /dev/null +++ b/src/wuttaweb/templates/auth/change_password.mako @@ -0,0 +1,7 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Change Password</%def> + + +${parent.body()} diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/auth/login.mako similarity index 100% rename from src/wuttaweb/templates/login.mako rename to src/wuttaweb/templates/auth/login.mako diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 7aba37c..dd51690 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -316,6 +316,7 @@ <div class="navbar-item has-dropdown is-hoverable"> <a class="navbar-link">${request.user}</a> <div class="navbar-dropdown"> + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> diff --git a/src/wuttaweb/templates/deform/checked_password.pt b/src/wuttaweb/templates/deform/checked_password.pt new file mode 100644 index 0000000..624f8a8 --- /dev/null +++ b/src/wuttaweb/templates/deform/checked_password.pt @@ -0,0 +1,13 @@ +<div tal:define="name name|field.name; + vmodel vmodel|'model_'+name;"> + ${field.start_mapping()} + <b-input name="${name}" + value="${field.widget.redisplay and cstruct or ''}" + type="password" + placeholder="Password" /> + <b-input name="${name}-confirm" + value="${field.widget.redisplay and confirm or ''}" + type="password" + placeholder="Confirm Password" /> + ${field.end_mapping()} +</div> diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako index efa320f..81b6ece 100644 --- a/src/wuttaweb/templates/form.mako +++ b/src/wuttaweb/templates/form.mako @@ -1,6 +1,12 @@ ## -*- coding: utf-8; -*- <%inherit file="/page.mako" /> +<%def name="page_content()"> + <div style="margin-top: 2rem; width: 50%;"> + ${form.render_vue_tag()} + </div> +</%def> + <%def name="render_this_page_template()"> ${parent.render_this_page_template()} ${form.render_vue_template()} diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index 0151632..11767fd 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -9,7 +9,7 @@ % endfor </section> - <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;"> + <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;"> % if form.show_button_reset: <b-button native-type="reset"> diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 1cf7804..a8d059f 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -43,7 +43,7 @@ def get_form_data(request): # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr if not request.POST and ( getattr(request, 'is_xhr', False) - or request.content_type == 'application/json'): + or getattr(request, 'content_type', None) == 'application/json'): return request.json_body return request.POST diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py index 981afbd..9fc838c 100644 --- a/src/wuttaweb/views/auth.py +++ b/src/wuttaweb/views/auth.py @@ -25,7 +25,7 @@ Auth Views """ import colander -from deform.widget import TextInputWidget, PasswordWidget +from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget from wuttaweb.views import View from wuttaweb.db import Session @@ -45,7 +45,7 @@ class AuthView(View): Upon successful login, user is redirected to home page. * route: ``login`` - * template: ``/login.mako`` + * template: ``/auth/login.mako`` """ auth = self.app.get_auth_handler() @@ -138,6 +138,66 @@ class AuthView(View): referrer = self.request.route_url('login') return self.redirect(referrer, headers=headers) + def change_password(self): + """ + View allowing a user to change their own password. + + This view shows a change-password form, and handles its + submission. If successful, user is redirected to home page. + + If current user is not authenticated, no form is shown and + user is redirected to home page. + + * route: ``change_password`` + * template: ``/auth/change_password.mako`` + """ + if not self.request.user: + return self.redirect(self.request.route_url('home')) + + form = self.make_form(schema=self.change_password_make_schema(), + show_button_reset=True) + + data = form.validate() + if data: + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) + self.request.session.flash("Your password has been changed.") + # TODO: should use request.get_referrer() instead + referrer = self.request.route_url('home') + return self.redirect(referrer) + + return {'index_title': str(self.request.user), + 'form': form} + + def change_password_make_schema(self): + schema = colander.Schema() + + schema.add(colander.SchemaNode( + colander.String(), + name='current_password', + widget=PasswordWidget(), + validator=self.change_password_validate_current_password)) + + schema.add(colander.SchemaNode( + colander.String(), + name='new_password', + widget=CheckedPasswordWidget(), + validator=self.change_password_validate_new_password)) + + return schema + + def change_password_validate_current_password(self, node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("Current password is incorrect.") + + def change_password_validate_new_password(self, node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if auth.check_user_password(user, value): + node.raise_invalid("New password must be different from old password.") + @classmethod def defaults(cls, config): cls._auth_defaults(config) @@ -149,13 +209,19 @@ class AuthView(View): config.add_route('login', '/login') config.add_view(cls, attr='login', route_name='login', - renderer='/login.mako') + renderer='/auth/login.mako') # logout config.add_route('logout', '/logout') config.add_view(cls, attr='logout', route_name='logout') + # change password + config.add_route('change_password', '/change-password') + config.add_view(cls, attr='change_password', + route_name='change_password', + renderer='/auth/change_password.mako') + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 96a0805..27e2109 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8; -*- from unittest import TestCase +from unittest.mock import MagicMock import colander import deform @@ -179,12 +180,41 @@ class TestForm(TestCase): def test_render_vue_field(self): self.pyramid_config.include('pyramid_deform') - schema = self.make_schema() form = self.make_form(schema=schema) + dform = form.get_deform() + + # typical html = form.render_vue_field('foo') self.assertIn('<b-field :horizontal="true" label="Foo">', html) self.assertIn('<b-input name="foo"', html) + # nb. no error message + self.assertNotIn('message', html) + + # with single "static" error + dform['foo'].error = MagicMock(msg="something is wrong") + html = form.render_vue_field('foo') + self.assertIn(' message="something is wrong"', html) + + # with single "dynamic" error + dform['foo'].error = MagicMock(msg="`something is wrong`") + html = form.render_vue_field('foo') + self.assertIn(':message="`something is wrong`"', html) + + def test_get_field_errors(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + dform = form.get_deform() + + # no error + errors = form.get_field_errors('foo') + self.assertEqual(len(errors), 0) + + # simple error + dform['foo'].error = MagicMock(msg="something is wrong") + errors = form.get_field_errors('foo') + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0], "something is wrong") def test_get_vue_field_value(self): schema = self.make_schema() diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py index 495dac1..b22989f 100644 --- a/tests/views/test_auth.py +++ b/tests/views/test_auth.py @@ -70,3 +70,75 @@ class TestAuthView(TestCase): redirect = view.logout() self.request.session.delete.assert_called_once_with() self.assertIsInstance(redirect, HTTPFound) + + def test_change_password(self): + view = mod.AuthView(self.request) + auth = self.app.get_auth_handler() + + # unauthenticated user is redirected + redirect = view.change_password() + self.assertIsInstance(redirect, HTTPFound) + + # now "login" the user, and set initial password + self.request.user = self.user + auth.set_user_password(self.user, 'foo') + self.session.commit() + + # view should now return context w/ form + context = view.change_password() + self.assertIn('form', context) + + # submit valid form, ensure password is changed + # (nb. this also would redirect user to home page) + self.request.method = 'POST' + self.request.POST = { + 'current_password': 'foo', + # nb. new_password requires colander mapping structure + '__start__': 'new_password:mapping', + 'new_password': 'bar', + 'new_password-confirm': 'bar', + '__end__': 'new_password:mapping', + } + redirect = view.change_password() + self.assertIsInstance(redirect, HTTPFound) + self.session.commit() + self.session.refresh(self.user) + self.assertFalse(auth.check_user_password(self.user, 'foo')) + self.assertTrue(auth.check_user_password(self.user, 'bar')) + + # at this point 'foo' is the password, now let's submit some + # invalid forms and make sure we get back a context w/ form + + # first try empty data + self.request.POST = {} + context = view.change_password() + self.assertIn('form', context) + dform = context['form'].get_deform() + self.assertEqual(dform['current_password'].errormsg, "Required") + self.assertEqual(dform['new_password'].errormsg, "Required") + + # now try bad current password + self.request.POST = { + 'current_password': 'blahblah', + '__start__': 'new_password:mapping', + 'new_password': 'baz', + 'new_password-confirm': 'baz', + '__end__': 'new_password:mapping', + } + context = view.change_password() + self.assertIn('form', context) + dform = context['form'].get_deform() + self.assertEqual(dform['current_password'].errormsg, "Current password is incorrect.") + + # now try bad new password + self.request.POST = { + 'current_password': 'bar', + '__start__': 'new_password:mapping', + 'new_password': 'bar', + 'new_password-confirm': 'bar', + '__end__': 'new_password:mapping', + } + context = view.change_password() + self.assertIn('form', context) + dform = context['form'].get_deform() + self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.") From fc339ba81bbacca8490841c4e2a98836422ebfec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 14:21:54 -0500 Subject: [PATCH 09/11] feat: add support for admin user to become / stop being root --- src/wuttaweb/auth.py | 6 +- src/wuttaweb/subscribers.py | 51 ++++++++++- src/wuttaweb/templates/base.mako | 35 +++++++- src/wuttaweb/views/auth.py | 54 ++++++++++++ src/wuttaweb/views/base.py | 8 ++ tests/test_auth.py | 6 ++ tests/test_subscribers.py | 141 ++++++++++++++++++++++++++++--- tests/views/test_auth.py | 50 ++++++++++- tests/views/test_base.py | 6 +- 9 files changed, 335 insertions(+), 22 deletions(-) diff --git a/src/wuttaweb/auth.py b/src/wuttaweb/auth.py index 0c2f26d..de9b868 100644 --- a/src/wuttaweb/auth.py +++ b/src/wuttaweb/auth.py @@ -138,9 +138,13 @@ class WuttaSecurityPolicy: return self.session_helper.forget(request, **kw) def permits(self, request, context, permission): + + # nb. root user can do anything + if getattr(request, 'is_root', False): + return True + config = request.registry.settings['wutta_config'] app = config.get_app() auth = app.get_auth_handler() - user = self.identity(request) return auth.has_permission(self.db_session, user, permission) diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index eebefb4..1b711e3 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -63,6 +63,16 @@ def new_request(event): Reference to the app :term:`config object`. + .. method:: request.get_referrer(default=None) + + Request method to get the "canonical" HTTP referrer value. + This has logic to check for referrer in the request params, + user session etc. + + :param default: Optional default URL if none is found in + request params/session. If no default is specified, + the ``'home'`` route is used. + .. attribute:: request.use_oruga Flag indicating whether the frontend should be displayed using @@ -75,6 +85,19 @@ def new_request(event): request.wutta_config = config + def get_referrer(default=None): + if request.params.get('referrer'): + return request.params['referrer'] + if request.session.get('referrer'): + return request.session.pop('referrer') + referrer = getattr(request, 'referrer', None) + if (not referrer or referrer == request.current_route_url() + or not referrer.startswith(request.host_url)): + referrer = default or request.route_url('home') + return referrer + + request.get_referrer = get_referrer + def use_oruga(request): spec = config.get('wuttaweb.oruga_detector.spec') if spec: @@ -104,22 +127,44 @@ def new_request_set_user(event, db_session=None): :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance (if logged in), or ``None``. - :param db_session: Optional :term:`db session` to use, instead of - :class:`wuttaweb.db.Session`. Probably only useful for tests. + .. attribute:: request.is_admin + + Flag indicating whether current user is a member of the + Administrator role. + + .. attribute:: request.is_root + + Flag indicating whether user is currently elevated to root + privileges. This is only possible if :attr:`request.is_admin` + is also true. """ request = event.request config = request.registry.settings['wutta_config'] app = config.get_app() - model = app.model def user(request): uuid = request.authenticated_userid if uuid: session = db_session or Session() + model = app.model return session.get(model.User, uuid) request.set_property(user, reify=True) + def is_admin(request): + auth = app.get_auth_handler() + return auth.user_is_admin(request.user) + + request.set_property(is_admin, reify=True) + + def is_root(request): + if request.is_admin: + if request.session.get('is_root', False): + return True + return False + + request.set_property(is_root, reify=True) + def before_render(event): """ diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index dd51690..b04c980 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -314,8 +314,29 @@ <%def name="render_user_menu()"> % if request.user: <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${request.user}</a> + <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a> <div class="navbar-dropdown"> + % if request.is_root: + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ## TODO + ## ${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"> + Stop being root + </a> + ${h.end_form()} + % elif request.is_admin: + ${h.form(url('become_root'), ref='startBeingRootForm')} + ## TODO + ## ${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"> + Become root + </a> + ${h.end_form()} + % endif ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> @@ -359,6 +380,18 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, + + % if request.is_admin: + + startBeingRoot() { + this.$refs.startBeingRootForm.submit() + }, + + stopBeingRoot() { + this.$refs.stopBeingRootForm.submit() + }, + + % endif }, } diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py index 9fc838c..389271b 100644 --- a/src/wuttaweb/views/auth.py +++ b/src/wuttaweb/views/auth.py @@ -198,6 +198,48 @@ class AuthView(View): if auth.check_user_password(user, value): node.raise_invalid("New password must be different from old password.") + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + + This is only allowed if current (authenticated) user is a + member of the Administrator role. Also note that GET is not + allowed for this view, only POST. + + See also :meth:`stop_root()`. + """ + if self.request.method != 'POST': + raise self.forbidden() + + if not self.request.is_admin: + raise self.forbidden() + + self.request.session['is_root'] = True + self.request.session.flash("You have been elevated to 'root' and now have full system access") + + url = self.request.get_referrer() + return self.redirect(url) + + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + + Also note that GET is not allowed for this view, only POST. + + See also :meth:`become_root()`. + """ + if self.request.method != 'POST': + raise self.forbidden() + + if not self.request.is_admin: + raise self.forbidden() + + self.request.session['is_root'] = False + self.request.session.flash("Your normal system access has been restored") + + url = self.request.get_referrer() + return self.redirect(url) + @classmethod def defaults(cls, config): cls._auth_defaults(config) @@ -222,6 +264,18 @@ class AuthView(View): route_name='change_password', renderer='/auth/change_password.mako') + # become root + config.add_route('become_root', '/root/yes', + request_method='POST') + config.add_view(cls, attr='become_root', + route_name='become_root') + + # stop root + config.add_route('stop_root', '/root/no', + request_method='POST') + config.add_view(cls, attr='stop_root', + route_name='stop_root') + def defaults(config, **kwargs): base = globals() diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index e7bfea3..e412ed2 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -55,6 +55,14 @@ class View: self.config = self.request.wutta_config self.app = self.config.get_app() + def forbidden(self): + """ + Convenience method, to raise a HTTP 403 Forbidden exception:: + + raise self.forbidden() + """ + return httpexceptions.HTTPForbidden() + def make_form(self, **kwargs): """ Make and return a new :class:`~wuttaweb.forms.base.Form` diff --git a/tests/test_auth.py b/tests/test_auth.py index a6bea29..5d6c406 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -137,3 +137,9 @@ class TestWuttaSecurityPolicy(TestCase): self.user.roles.append(role) self.session.commit() self.assertTrue(self.policy.permits(self.request, None, 'baz.edit')) + + # now let's try another perm - we won't grant it, but will + # confirm user is denied access unless they become root + self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm')) + self.request.is_root = True + self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm')) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 63b6640..27c85c3 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -18,40 +18,78 @@ class TestNewRequest(TestCase): def setUp(self): self.config = WuttaConfig() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) + + def tearDown(self): + testing.tearDown() def make_request(self): request = testing.DummyRequest() - request.registry.settings = {'wutta_config': self.config} + # request.registry.settings = {'wutta_config': self.config} return request def test_wutta_config(self): - request = self.make_request() - event = MagicMock(request=request) + event = MagicMock(request=self.request) # request gets a new attr - self.assertFalse(hasattr(request, 'wutta_config')) + self.assertFalse(hasattr(self.request, 'wutta_config')) subscribers.new_request(event) - self.assertTrue(hasattr(request, 'wutta_config')) - self.assertIs(request.wutta_config, self.config) + self.assertTrue(hasattr(self.request, 'wutta_config')) + self.assertIs(self.request.wutta_config, self.config) def test_use_oruga_default(self): - request = self.make_request() - event = MagicMock(request=request) + event = MagicMock(request=self.request) # request gets a new attr, false by default - self.assertFalse(hasattr(request, 'use_oruga')) + self.assertFalse(hasattr(self.request, 'use_oruga')) subscribers.new_request(event) - self.assertFalse(request.use_oruga) + self.assertFalse(self.request.use_oruga) def test_use_oruga_custom(self): self.config.setdefault('wuttaweb.oruga_detector.spec', 'tests.test_subscribers:custom_oruga_detector') - request = self.make_request() - event = MagicMock(request=request) + event = MagicMock(request=self.request) # request gets a new attr, which should be true - self.assertFalse(hasattr(request, 'use_oruga')) + self.assertFalse(hasattr(self.request, 'use_oruga')) subscribers.new_request(event) - self.assertTrue(request.use_oruga) + self.assertTrue(self.request.use_oruga) + + def test_get_referrer(self): + event = MagicMock(request=self.request) + + def home(request): + pass + + self.pyramid_config.add_route('home', '/') + self.pyramid_config.add_view(home, route_name='home') + + self.assertFalse(hasattr(self.request, 'get_referrer')) + subscribers.new_request(event) + self.assertTrue(hasattr(self.request, 'get_referrer')) + + # default if no referrer, is home route + url = self.request.get_referrer() + self.assertEqual(url, self.request.route_url('home')) + + # can specify another default + url = self.request.get_referrer(default='https://wuttaproject.org') + self.assertEqual(url, 'https://wuttaproject.org') + + # or referrer can come from user session + self.request.session['referrer'] = 'https://rattailproject.org' + self.assertIn('referrer', self.request.session) + url = self.request.get_referrer() + self.assertEqual(url, 'https://rattailproject.org') + # nb. referrer should also have been removed from user session + self.assertNotIn('referrer', self.request.session) + + # or referrer can come from request params + self.request.params['referrer'] = 'https://kernel.org' + url = self.request.get_referrer() + self.assertEqual(url, 'https://kernel.org') def custom_oruga_detector(request): @@ -97,6 +135,81 @@ class TestNewRequestSetUser(TestCase): subscribers.new_request_set_user(event, db_session=self.session) self.assertIs(self.request.user, self.user) + def test_is_admin(self): + event = MagicMock(request=self.request) + + # anonymous user + self.assertFalse(hasattr(self.request, 'user')) + self.assertFalse(hasattr(self.request, 'is_admin')) + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIsNone(self.request.user) + self.assertFalse(self.request.is_admin) + + # reset + del self.request.is_admin + + # authenticated user, but still not an admin + self.request.user = self.user + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIs(self.request.user, self.user) + self.assertFalse(self.request.is_admin) + + # reset + del self.request.is_admin + + # but if we make them an admin, it changes + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + self.user.roles.append(admin) + self.session.commit() + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIs(self.request.user, self.user) + self.assertTrue(self.request.is_admin) + + def test_is_root(self): + event = MagicMock(request=self.request) + + # anonymous user + self.assertFalse(hasattr(self.request, 'user')) + self.assertFalse(hasattr(self.request, 'is_root')) + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIsNone(self.request.user) + self.assertFalse(self.request.is_root) + + # reset + del self.request.is_admin + del self.request.is_root + + # authenticated user, but still not an admin + self.request.user = self.user + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIs(self.request.user, self.user) + self.assertFalse(self.request.is_root) + + # reset + del self.request.is_admin + del self.request.is_root + + # even if we make them an admin, still not yet root + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + self.user.roles.append(admin) + self.session.commit() + subscribers.new_request_set_user(event, db_session=self.session) + self.assertIs(self.request.user, self.user) + self.assertTrue(self.request.is_admin) + self.assertFalse(self.request.is_root) + + # reset + del self.request.is_admin + del self.request.is_root + + # root status flag lives in user session + self.request.session['is_root'] = True + subscribers.new_request_set_user(event, db_session=self.session) + self.assertTrue(self.request.is_admin) + self.assertTrue(self.request.is_root) + class TestBeforeRender(TestCase): diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py index b22989f..d10e759 100644 --- a/tests/views/test_auth.py +++ b/tests/views/test_auth.py @@ -4,11 +4,12 @@ from unittest import TestCase from unittest.mock import MagicMock from pyramid import testing -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPFound, HTTPForbidden from wuttjamaican.conf import WuttaConfig from wuttaweb.views import auth as mod from wuttaweb.auth import WuttaSecurityPolicy +from wuttaweb.subscribers import new_request class TestAuthView(TestCase): @@ -19,7 +20,9 @@ class TestAuthView(TestCase): }) self.request = testing.DummyRequest(wutta_config=self.config, user=None) - self.pyramid_config = testing.setUp(request=self.request) + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) self.app = self.config.get_app() auth = self.app.get_auth_handler() @@ -142,3 +145,46 @@ class TestAuthView(TestCase): self.assertIn('form', context) dform = context['form'].get_deform() self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.") + + def test_become_root(self): + event = MagicMock(request=self.request) + new_request(event) # add request.get_referrer() + view = mod.AuthView(self.request) + + # GET not allowed + self.request.method = 'GET' + self.assertRaises(HTTPForbidden, view.become_root) + + # non-admin users also not allowed + self.request.method = 'POST' + self.request.is_admin = False + self.assertRaises(HTTPForbidden, view.become_root) + + # but admin users can become root + self.request.is_admin = True + self.assertNotIn('is_root', self.request.session) + redirect = view.become_root() + self.assertIsInstance(redirect, HTTPFound) + self.assertTrue(self.request.session['is_root']) + + def test_stop_root(self): + event = MagicMock(request=self.request) + new_request(event) # add request.get_referrer() + view = mod.AuthView(self.request) + + # GET not allowed + self.request.method = 'GET' + self.assertRaises(HTTPForbidden, view.stop_root) + + # non-admin users also not allowed + self.request.method = 'POST' + self.request.is_admin = False + self.assertRaises(HTTPForbidden, view.stop_root) + + # but admin users can stop being root + # (nb. there is no check whether user is currently root) + self.request.is_admin = True + self.assertNotIn('is_root', self.request.session) + redirect = view.stop_root() + self.assertIsInstance(redirect, HTTPFound) + self.assertFalse(self.request.session['is_root']) diff --git a/tests/views/test_base.py b/tests/views/test_base.py index 52c717a..103e005 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -3,7 +3,7 @@ from unittest import TestCase from pyramid import testing -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPFound, HTTPForbidden from wuttjamaican.conf import WuttaConfig from wuttaweb.views import base @@ -23,6 +23,10 @@ class TestView(TestCase): self.assertIs(self.view.config, self.config) self.assertIs(self.view.app, self.app) + def test_forbidden(self): + error = self.view.forbidden() + self.assertIsInstance(error, HTTPForbidden) + def test_make_form(self): form = self.view.make_form() self.assertIsInstance(form, Form) From 0e0460b83170d1c5ce58bf83a4c9043472f67bb7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:06:55 -0500 Subject: [PATCH 10/11] fix: allow custom user getter for `new_request_set_user()` hook --- src/wuttaweb/subscribers.py | 50 ++++++++++++++++++++++++++++--------- tests/test_subscribers.py | 2 +- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 1b711e3..63fe428 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -35,6 +35,7 @@ However some custom apps may need to supplement or replace the event hooks contained here, depending on the circumstance. """ +import functools import json import logging @@ -108,10 +109,29 @@ def new_request(event): request.set_property(use_oruga, reify=True) -def new_request_set_user(event, db_session=None): +def default_user_getter(request, db_session=None): + """ + This is the default function used to retrieve user object from + database. Result of this is then assigned to :attr:`request.user` + as part of the :func:`new_request_set_user()` hook. + """ + uuid = request.authenticated_userid + if uuid: + config = request.wutta_config + app = config.get_app() + model = app.model + session = db_session or Session() + return session.get(model.User, uuid) + + +def new_request_set_user( + event, + user_getter=default_user_getter, + db_session=None, +): """ Event hook called when processing a new :term:`request`, for sake - of setting the ``request.user`` property. + of setting the :attr:`request.user` and similar properties. The hook is auto-registered if this module is "included" by Pyramid config object. Or you can explicitly register it:: @@ -137,32 +157,38 @@ def new_request_set_user(event, db_session=None): Flag indicating whether user is currently elevated to root privileges. This is only possible if :attr:`request.is_admin` is also true. + + You may wish to "supplement" this hook by registering your own + custom hook and then invoking this one as needed. You can then + pass certain params to override only parts of the logic: + + :param user_getter: Optional getter function to retrieve the user + from database, instead of :func:`default_user_getter()`. + + :param db_session: Optional :term:`db session` to use, + instead of :class:`wuttaweb.db.Session`. """ request = event.request config = request.registry.settings['wutta_config'] app = config.get_app() - def user(request): - uuid = request.authenticated_userid - if uuid: - session = db_session or Session() - model = app.model - return session.get(model.User, uuid) - - request.set_property(user, reify=True) + # request.user + if db_session: + user_getter = functools.partial(user_getter, db_session=db_session) + request.set_property(user_getter, name='user', reify=True) + # request.is_admin def is_admin(request): auth = app.get_auth_handler() return auth.user_is_admin(request.user) - request.set_property(is_admin, reify=True) + # request.is_root def is_root(request): if request.is_admin: if request.session.get('is_root', False): return True return False - request.set_property(is_root, reify=True) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 27c85c3..419e130 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -103,7 +103,7 @@ class TestNewRequestSetUser(TestCase): 'wutta.db.default.url': 'sqlite://', }) - self.request = testing.DummyRequest() + self.request = testing.DummyRequest(wutta_config=self.config) self.pyramid_config = testing.setUp(request=self.request, settings={ 'wutta_config': self.config, }) From 17df2c0f5691fe0553414ec83d347b00293b5a22 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:32:46 -0500 Subject: [PATCH 11/11] =?UTF-8?q?bump:=20version=200.2.0=20=E2=86=92=200.3?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba15691..90fc273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.3.0 (2024-08-05) + +### Feat + +- add support for admin user to become / stop being root +- add view to change current user password +- add basic logo, favicon images +- add auth views, for login/logout +- add custom security policy, login/logout for pyramid +- add `wuttaweb.views.essential` module +- add initial/basic forms support +- add `wuttaweb.db` module, with `Session` +- add `util.get_form_data()` convenience function + +### Fix + +- allow custom user getter for `new_request_set_user()` hook + ## v0.2.0 (2024-07-14) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 52f460a..ce0044d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.2.0" +version = "0.3.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]