From a2ba88ca8f221f34774ca62459b9a4b9d20ef0df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Aug 2024 11:45:00 -0500 Subject: [PATCH] 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 + + + + """ 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 + + +${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 @@ 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('