-
- ## ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-
Welcome to ${base_meta.app_title()}
-
+
+
${base_meta.full_logo()}
+
Welcome to ${app.get_title()}
%def>
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index 3275301..a8d059f 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 getattr(request, 'content_type', None) == 'application/json'):
+ return request.json_body
+ return request.POST
+
+
def get_libver(
request,
key,
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/auth.py b/src/wuttaweb/views/auth.py
new file mode 100644
index 0000000..389271b
--- /dev/null
+++ b/src/wuttaweb/views/auth.py
@@ -0,0 +1,288 @@
+# -*- 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, CheckedPasswordWidget
+
+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: ``/auth/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)
+
+ 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.")
+
+ 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)
+
+ @classmethod
+ def _auth_defaults(cls, config):
+
+ # login
+ config.add_route('login', '/login')
+ config.add_view(cls, attr='login',
+ route_name='login',
+ 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')
+
+ # 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()
+
+ AuthView = kwargs.get('AuthView', base['AuthView'])
+ AuthView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py
index 3906c0b..e412ed2 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,38 @@ class View:
self.request = request
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`
+ 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/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
new file mode 100644
index 0000000..93c8149
--- /dev/null
+++ b/src/wuttaweb/views/essential.py
@@ -0,0 +1,45 @@
+# -*- 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
.
+#
+################################################################################
+"""
+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.auth`
+* :mod:`wuttaweb.views.common`
+"""
+
+
+def defaults(config, **kwargs):
+ mod = lambda spec: kwargs.get(spec, spec)
+
+ config.include(mod('wuttaweb.views.auth'))
+ config.include(mod('wuttaweb.views.common'))
+
+
+def includeme(config):
+ defaults(config)
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..27e2109
--- /dev/null
+++ b/tests/forms/test_base.py
@@ -0,0 +1,271 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+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('