diff --git a/Makefile b/Makefile index 7fd0fd6..62a7439 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ lint: ## check style with flake8 flake8 ordr tests test: ## run tests quickly with the default Python, ignoring functional tests - py.test + py.test -x coverage: ## check code coverage quickly with the default Python coverage run --source ordr -m pytest --ignore tests/_functional/ diff --git a/ordr/events.py b/ordr/events.py index bb4b9fb..b98da50 100644 --- a/ordr/events.py +++ b/ordr/events.py @@ -14,6 +14,7 @@ class UserNotification(object): :param pyramid.request.Request request: current request object :param ordr.models.account.Users account: send notification to this user :param dict data: additional data to pass to the mail template + :param str send_to: optional email address overriding user's email address ''' #: subject of the notification @@ -22,10 +23,11 @@ class UserNotification(object): #: template to render template = None - def __init__(self, request, account, data=None): + def __init__(self, request, account, data=None, send_to=None): self.request = request self.account = account self.data = data + self.send_to = send_to or account.email class ActivationNotification(UserNotification): @@ -35,6 +37,13 @@ class ActivationNotification(UserNotification): template = 'ordr:templates/emails/activation.jinja2' +class ChangeEmailNotification(UserNotification): + ''' user notification for verifying a change of the mail address ''' + + subject = '[ordr] Verify New Email Address' + template = 'ordr:templates/emails/email_change.jinja2' + + class OrderStatusNotification(UserNotification): ''' user notification for order status change ''' @@ -69,7 +78,7 @@ def notify_user(event): message = Message( subject=event.subject, sender=event.request.registry.settings['mail.default_sender'], - recipients=[event.account.email], + recipients=[event.send_to], html=body ) mailer = get_mailer(event.request.registry) diff --git a/ordr/models/account.py b/ordr/models/account.py index e35affe..0745bcf 100644 --- a/ordr/models/account.py +++ b/ordr/models/account.py @@ -53,6 +53,7 @@ class TokenSubject(enum.Enum): REGISTRATION = 'registration' #: validate email for new user RESET_PASSWORD = 'reset_password' #: reset a forgotten password + CHANGE_EMAIL = 'change_email' #: validate an email change # database driven models diff --git a/ordr/resources/__init__.py b/ordr/resources/__init__.py index 9069530..be6927b 100644 --- a/ordr/resources/__init__.py +++ b/ordr/resources/__init__.py @@ -2,7 +2,11 @@ from pyramid.security import Allow, Everyone, DENY_ALL -from .account import RegistrationResource, PasswordResetResource +from .account import ( + RegistrationResource, + PasswordResetResource, + AccountResource + ) class RootResource: @@ -36,6 +40,7 @@ class RootResource: map = { 'register': RegistrationResource, 'forgot': PasswordResetResource, + 'account': AccountResource, } child_class = map[key] return child_class(name=key, parent=self) diff --git a/ordr/resources/account.py b/ordr/resources/account.py index d1b38d9..a7689e5 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -2,10 +2,16 @@ import deform -from pyramid.security import Allow, Everyone, DENY_ALL +from pyramid.security import Allow, Authenticated, Everyone, DENY_ALL from ordr.models.account import Token, TokenSubject -from ordr.schemas.account import RegistrationSchema, ResetPasswordSchema +from ordr.schemas.account import ( + ChangePasswordSchema, + RegistrationSchema, + ResetPasswordSchema, + SettingsSchema + ) + from .helpers import BaseChildResource @@ -17,9 +23,9 @@ class RegistrationTokenResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = 'registration' - + def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] @@ -32,20 +38,20 @@ class RegistrationResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = 'registration' def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] - + def __getitem__(self, key): ''' returns a resource for a valid registration token ''' token = Token.retrieve(self.request, key, TokenSubject.REGISTRATION) if token is None: raise KeyError(f'Token {key} not found') return RegistrationTokenResource(name=key, parent=self, model=token) - + def get_registration_form(self, **kwargs): ''' returns the registration form''' settings = { @@ -70,9 +76,9 @@ class PasswordResetTokenResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = None - + def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] @@ -96,16 +102,87 @@ class PasswordResetResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = None def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] - + def __getitem__(self, key): ''' returns a resource for a valid reset password token ''' token = Token.retrieve(self.request, key, TokenSubject.RESET_PASSWORD) if token is None: raise KeyError(f'Token {key} not found') return PasswordResetTokenResource(name=key, parent=self, model=token) + + +class ChangeEmailTokenResource(BaseChildResource): + ''' Resource for changing the email address + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, self.model.owner.principal, 'edit'), DENY_ALL] + + +class AccountResource(BaseChildResource): + ''' The resource for changing account settings and passwords + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __init__(self, name, parent, model=None): + ''' Create the resource + + :param str name: the name of the resource + :param parent: the parent resouce + :param model: optional data model for the resource + + If model is not set, the current user will be used + ''' + super().__init__(name, parent, model) + self.model = model or getattr(self.request, 'user', None) + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, Authenticated, 'edit'), DENY_ALL] + + def __getitem__(self, key): + ''' returns a resource for a valid change email token ''' + token = Token.retrieve(self.request, key, TokenSubject.CHANGE_EMAIL) + if token is None: + raise KeyError(f'Token {key} not found') + return ChangeEmailTokenResource(name=key, parent=self, model=token) + + def get_settings_form(self, **kwargs): + ''' returns the account settings form ''' + settings = { + 'buttons': ( + deform.Button(name='change', title='Change Settings'), + deform.Button(name='cancel', title='Cancel'), + ) + } + settings.update(kwargs) + return self._prepare_form(SettingsSchema, **settings) + + def get_password_form(self, **kwargs): + ''' returns the change password form ''' + settings = { + 'buttons': ( + deform.Button(name='change', title='Change Password'), + deform.Button(name='cancel', title='Cancel'), + ) + } + settings.update(kwargs) + return self._prepare_form(ChangePasswordSchema, **settings) diff --git a/ordr/schemas/__init__.py b/ordr/schemas/__init__.py index a6fa136..cf710f3 100644 --- a/ordr/schemas/__init__.py +++ b/ordr/schemas/__init__.py @@ -5,7 +5,7 @@ import deform from deform.renderer import configure_zpt_renderer -from .helpers import ( +from .validators import ( deferred_csrf_default, deferred_csrf_validator ) diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py index 215bd18..6f87c48 100644 --- a/ordr/schemas/account.py +++ b/ordr/schemas/account.py @@ -3,9 +3,10 @@ import deform from . import CSRFSchema -from .helpers import ( +from .validators import ( deferred_unique_email_validator, deferred_unique_username_validator, + deferred_password_validator ) @@ -47,10 +48,54 @@ class RegistrationSchema(CSRFSchema): class ResetPasswordSchema(CSRFSchema): - ''' reset a forgotten password registration ''' + ''' reset a forgotten password ''' password = colander.SchemaNode( colander.String(), widget=deform.widget.CheckedPasswordWidget(), validator=colander.Length(min=8) ) + + +class SettingsSchema(CSRFSchema): + ''' new user registration ''' + + username = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextInputWidget(readonly=True) + ) + + first_name = colander.SchemaNode( + colander.String() + ) + + last_name = colander.SchemaNode( + colander.String() + ) + + email = colander.SchemaNode( + colander.String(), + validator=deferred_unique_email_validator + ) + + confirmation = colander.SchemaNode( + colander.String(), + widget=deform.widget.PasswordWidget(), + validator=deferred_password_validator + ) + + +class ChangePasswordSchema(CSRFSchema): + ''' change the password ''' + + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget(), + validator=colander.Length(min=8) + ) + + confirmation = colander.SchemaNode( + colander.String(), + widget=deform.widget.PasswordWidget(), + validator=deferred_password_validator + ) diff --git a/ordr/schemas/helpers.py b/ordr/schemas/validators.py similarity index 100% rename from ordr/schemas/helpers.py rename to ordr/schemas/validators.py diff --git a/ordr/templates/account/password_changed.jinja2 b/ordr/templates/account/password_changed.jinja2 new file mode 100644 index 0000000..a68c143 --- /dev/null +++ b/ordr/templates/account/password_changed.jinja2 @@ -0,0 +1,16 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
You can now log in with your new password.
+Happy ordering +
New notifications will be sent to {{request.user.email}}.
+Happy ordering +
+ Please verify your new email address for the account "{{ user.username }}" by following this link + {{ request.resource_url(context, data.token.hash) }} +
+The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}. +
+ Regards,
+
+ ordr
+
+ Please don't respont to this email! This is an automatically generated notification by the system. + +
+ +