From 3b6b3173776829986577133919e170a8e3f171f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar 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 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 . +# +################################################################################ +""" +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 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 . +# +################################################################################ +""" +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 . +# +################################################################################ +""" +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 + + + + 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 + + + + .. todo:: + + Why can't Sphinx render the above code block as 'html' ? + + It acts like it can't handle a `` + + + +${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; -*- + + + + 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, '') + + 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(' + + + +${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 . +# +################################################################################ +""" +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 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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 - ${base_meta.header_logo()} -
- ${base_meta.global_title()} +
+ ${base_meta.header_logo()} +
+ ${base_meta.global_title()} +
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 @@ +
+ ${field.start_mapping()} + + + ${field.end_mapping()} +
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()"> +
+ ${form.render_vue_tag()} +
+ + <%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 -
+
% if form.show_button_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('', html) self.assertIn(' 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: