diff --git a/.gitignore b/.gitignore index 7471979..6d350df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # database ordr.sqlite +# helper directories +ordr/templates/deform_origs/ +mail/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/development.ini b/development.ini index 756d0e2..b3a9dbc 100644 --- a/development.ini +++ b/development.ini @@ -12,6 +12,7 @@ pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en pyramid.includes = + pyramid_mailer.debug pyramid_debugtoolbar pyramid_listing @@ -32,6 +33,16 @@ passlib.default = argon2 # flag every encryption method as deprecated except the first one passlib.deprecated = auto +# time a user token is valid (in minutes) +token_expiry.change_email = 120 +token_expiry.registration = 120 +token_expiry.reset_password = 120 + + +# email delivery +mail.host = localhost +mail.port = 2525 +mail.default_sender = ordr@example.com # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. diff --git a/ordr/__init__.py b/ordr/__init__.py index 2d775e5..96d8bd5 100644 --- a/ordr/__init__.py +++ b/ordr/__init__.py @@ -20,4 +20,7 @@ def main(global_config, **settings): config.include('.schemas') config.include('.security') config.include('.views') + + config.scan() + return config.make_wsgi_app() diff --git a/ordr/events.py b/ordr/events.py new file mode 100644 index 0000000..3bbfb60 --- /dev/null +++ b/ordr/events.py @@ -0,0 +1,72 @@ +''' custom events and event subsribers ''' + +from pyramid.events import subscriber +from pyramid.renderers import render +from pyramid_mailer import get_mailer +from pyramid_mailer.message import Message + + +# custom events + +class UserNotification(object): + ''' base class for user notification emails + + :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 + ''' + + #: subject of the notification + subject = None + + #: template to render + template = None + + def __init__(self, request, account, data=None): + self.request = request + self.account = account + self.data = data + + +class ActivationNotification(UserNotification): + ''' user notification for account activation ''' + subject = '[ordr] Your account was activated' + template = 'ordr:templates/emails/activation.jinja2' + + +class OrderStatusNotification(UserNotification): + ''' user notification for order status change ''' + subject = '[ordr] Order Status Change' + template = 'ordr:templates/emails/order.jinja2' + + +class PasswordResetNotification(UserNotification): + ''' user notification for password reset link ''' + subject = '[ordr] Password Reset' + template = 'ordr:templates/emails/password_reset.jinja2' + + +class RegistrationNotification(UserNotification): + ''' user notification for account activation ''' + subject = '[ordr] Please verify your email address' + template = 'ordr:templates/emails/registration.jinja2' + + +# subsribers for events + +@subscriber(UserNotification) +def notify_user(event): + ''' notify a user about an event ''' + body = render( + event.template, + {'user': event.account, 'data': event.data}, + event.request + ) + message = Message( + subject=event.subject, + sender=event.request.registry.settings['mail.default_sender'], + recipients=[event.account.email], + html=body + ) + mailer = get_mailer(event.request.registry) + mailer.send(message) diff --git a/ordr/resources/account.py b/ordr/resources/account.py index ea2bf61..34408d6 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -28,9 +28,9 @@ class RegistrationResource(BaseChildResource): 'buttons': ( deform.Button(name='create', title='Create Account'), deform.Button( - title='Cancel', - type='link', - value=self.request.resource_url(self.request.root), + title='Cancel', + type='link', + value=self.request.resource_url(self.request.root), css_class='btn btn-secondary' ) ), diff --git a/ordr/resources/helpers.py b/ordr/resources/helpers.py index 9efb2df..15c84af 100644 --- a/ordr/resources/helpers.py +++ b/ordr/resources/helpers.py @@ -1,7 +1,5 @@ ''' Resources (sub) package, used to connect URLs to views ''' -from pyramid.security import Allow, Everyone, DENY_ALL - class BaseChildResource: diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py index c85004f..1e6f838 100644 --- a/ordr/schemas/account.py +++ b/ordr/schemas/account.py @@ -20,15 +20,15 @@ class RegistrationSchema(CSRFSchema): readonly=True ), description='automagically generated for you', - validator=deferred_unique_username_validator, + validator=deferred_unique_username_validator, oid='registration_username' ) first_name = colander.SchemaNode( - colander.String(), + colander.String(), oid='registration_first_name' ) last_name = colander.SchemaNode( - colander.String(), + colander.String(), oid='registration_last_name' ) email = colander.SchemaNode( @@ -37,6 +37,6 @@ class RegistrationSchema(CSRFSchema): ) password = colander.SchemaNode( colander.String(), - widget=deform.widget.CheckedPasswordWidget() + widget=deform.widget.CheckedPasswordWidget(), + validator=colander.Length(min=8) ) - diff --git a/ordr/scripts/initializedb.py b/ordr/scripts/initializedb.py index 24bb492..eaba1bb 100644 --- a/ordr/scripts/initializedb.py +++ b/ordr/scripts/initializedb.py @@ -9,6 +9,8 @@ from pyramid.paster import ( from pyramid.scripts.common import parse_vars +from urllib.parse import urlparse + from ..models.meta import Base from ..models import ( get_engine, @@ -33,9 +35,15 @@ def main(argv=sys.argv): setup_logging(config_uri) settings = get_appsettings(config_uri, options=options) + # remove an existing database + sqlalchemy_url = urlparse(settings['sqlalchemy.url']) + path = os.path.abspath(sqlalchemy_url.path) + if os.path.exists(path): + os.remove(path) + engine = get_engine(settings) Base.metadata.create_all(engine) - + session_factory = get_session_factory(engine) with transaction.manager: diff --git a/ordr/templates/emails/registration.jinja2 b/ordr/templates/emails/registration.jinja2 new file mode 100755 index 0000000..73247dc --- /dev/null +++ b/ordr/templates/emails/registration.jinja2 @@ -0,0 +1,25 @@ + + +
+ ++ Please verify your 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. + +
+ + diff --git a/ordr/views/__init__.py b/ordr/views/__init__.py index 3388dbe..e6fe938 100644 --- a/ordr/views/__init__.py +++ b/ordr/views/__init__.py @@ -12,4 +12,3 @@ def includeme(config): config.add_static_view('static', 'ordr:static', cache_max_age=age) config.add_static_view('deform', 'deform:static', cache_max_age=age) - config.scan() diff --git a/ordr/views/registration.py b/ordr/views/registration.py index 77e1053..30252cf 100644 --- a/ordr/views/registration.py +++ b/ordr/views/registration.py @@ -3,7 +3,11 @@ import deform from pyramid.httpexceptions import HTTPFound from pyramid.view import view_config -# from ordr.models import User +from ordr.models.account import User, Role, TokenSubject +from ordr.events import RegistrationNotification + +# below this password length a warning is displayed +MIN_PW_LENGTH = 12 @view_config( @@ -13,11 +17,11 @@ from pyramid.view import view_config renderer='ordr:templates/account/registration_form.jinja2' ) def registration_form(context, request): + ''' show registration form ''' form = context.get_registration_form() return {'form': form} - @view_config( context='ordr.resources.account.RegistrationResource', permission='view', @@ -25,11 +29,30 @@ def registration_form(context, request): renderer='ordr:templates/account/registration_form.jinja2' ) def registration_form_processing(context, request): + ''' process registration form ''' + if 'create' not in request.POST: + return HTTPFound(request.resource_url(request.root)) form = context.get_registration_form() - if 'create' in request.POST: - data = request.POST.items() - try: - appstruct = form.validate(data) - except deform.ValidationFailure as e: - pass - return {'form': form} + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation successfull, create user + account = User( + username=appstruct['username'], + first_name=appstruct['first_name'], + last_name=appstruct['last_name'], + email=appstruct['email'], + role=Role.UNVALIDATED + ) + account.set_password(appstruct['password']) + request.dbsession.add(account) + + # create a verify-new-account token and send email + token = account.issue_token(request, TokenSubject.REGISTRATION) + notification = RegistrationNotification(request, account, {'token': token}) + request.registry.notify(notification) + + return HTTPFound(request.resource_url(context, 'verify')) diff --git a/setup.py b/setup.py index 98c1526..8e30932 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ requires = [ 'pyramid_debugtoolbar', 'pyramid_jinja2', 'pyramid_listing', + 'pyramid_mailer', 'pyramid_retry', 'pyramid_tm', 'SQLAlchemy', diff --git a/tests/__init__.py b/tests/__init__.py index 167d996..03db5b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,6 +2,7 @@ import pytest import transaction from pyramid import testing +from pyramid.csrf import get_csrf_token APP_SETTINGS = { @@ -10,7 +11,8 @@ APP_SETTINGS = { 'session.auto_csrf': True, 'passlib.schemes': 'argon2 bcrypt', 'passlib.default': 'argon2', - 'passlib.deprecated': 'auto' + 'passlib.deprecated': 'auto', + 'mail.default_sender': 'ordr@example.com' } EXAMPLE_USER_DATA = { @@ -31,6 +33,7 @@ def app_config(): with testing.testConfig(settings=APP_SETTINGS) as config: config.include('pyramid_jinja2') config.include('pyramid_listing') + config.include('pyramid_mailer.testing') yield config @@ -72,3 +75,10 @@ def get_example_user(role): ) user.set_password(first_name) return user + + +def get_post_request(dbsession, data): + request = testing.DummyRequest() + post_data = {'csrf_token': get_csrf_token(request)} + post_data.update(data) + return testing.DummyRequest(dbsession=dbsession, POST=post_data) diff --git a/tests/events.py b/tests/events.py new file mode 100644 index 0000000..09dfc33 --- /dev/null +++ b/tests/events.py @@ -0,0 +1,36 @@ +''' Tests for ordr.events ''' + +from datetime import datetime +from pyramid.testing import DummyRequest +from pyramid_mailer import get_mailer + +from . import app_config, get_example_user # noqa: F401 + + +def test_user_notification_init(app_config): # noqa: F811 + ''' test creation of user notification events ''' + from ordr.events import UserNotification + from ordr.models.account import Role + + user = get_example_user(Role.USER) + notification = UserNotification('request', user, 'data') + + assert notification.request == 'request' + assert notification.account == user + assert notification.data == 'data' + + +def test_notify_user(app_config): # noqa: F811 + from ordr.events import RegistrationNotification, notify_user + from ordr.models.account import Token, Role + + request = DummyRequest() + user = get_example_user(Role.USER) + token = Token(expires=datetime.utcnow(), hash='some_hash') + notification = RegistrationNotification(request, user, {'token': token}) + notify_user(notification) + mailer = get_mailer(request.registry) + last_mail = mailer.outbox[-1] + + assert 'Please verify your email address ' in last_mail.html + assert 'http://example.com//some_hash' in last_mail.html diff --git a/tests/resources/account.py b/tests/resources/account.py index 1fba2f0..ca5c721 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -9,6 +9,7 @@ def test_registration_acl(): resource = RegistrationResource('some request', 'a name', 'the parent') assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] + def test_registration_get_registration_form(): from pyramid.security import Allow, Everyone, DENY_ALL from ordr.resources.account import RegistrationResource @@ -18,5 +19,5 @@ def test_registration_get_registration_form(): form = resource.get_registration_form() assert isinstance(form, deform.Form) assert len(form.buttons) == 2 - assert form.buttons[0].title == 'Create account' + assert form.buttons[0].title == 'Create Account' assert form.buttons[1].title == 'Cancel' diff --git a/tests/views/registration.py b/tests/views/registration.py index 255543c..50cff93 100644 --- a/tests/views/registration.py +++ b/tests/views/registration.py @@ -1,12 +1,88 @@ import pytest +import deform from pyramid.httpexceptions import HTTPFound from pyramid.testing import DummyRequest -from .. import app_config, dbsession # noqa: F401 +from .. import app_config, dbsession, get_post_request # noqa: F401 -def test_faq(): +REGISTRATION_FORM_DATA = { + 'username': 'AmyMcDonald', + 'first_name': 'Amy', + 'last_name': 'Mc Donald', + 'email': 'amy.mcdonald@example.com', + '__start__': 'password:mapping', + 'password': 'Make Amy McDonald A Rich Girl Fund', + 'password-confirm': 'Make Amy McDonald A Rich Girl Fund', + '__end__': 'password:mapping', + 'create': 'create account' + } + + +def test_registration_form(): + from ordr.resources.account import RegistrationResource + from ordr.schemas.account import RegistrationSchema from ordr.views.registration import registration_form - result = registration_form(None, None) - assert result == {} + + request = DummyRequest() + context = RegistrationResource(request=request, name=None, parent=None) + result = registration_form(context, None) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, RegistrationSchema) + + +def test_registration_form_valid(dbsession): # noqa: F811 + from ordr.models.account import User, Role, TokenSubject + from ordr.resources.account import RegistrationResource + from ordr.views.registration import registration_form_processing + + data = REGISTRATION_FORM_DATA.copy() + request = get_post_request(dbsession, data) + context = RegistrationResource(request=request, name=None, parent=None) + result = registration_form_processing(context, request) + + # return value of function call + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com/verify' + + # user should be added to database + user = dbsession.query(User).first() + assert user.username == data['username'] + assert user.first_name == data['first_name'] + assert user.last_name == data['last_name'] + assert user.email == data['email'] + assert user.check_password(data['password']) + assert user.role == Role.UNVALIDATED + + # a token should be created + token = user.tokens[0] + assert token.subject == TokenSubject.REGISTRATION + + # a verification email should be sent + # this is tested in the functional test since request.registry.notify + # doesn't know about event subscribers in the unittest + + +def test_registration_form_invalid(dbsession): # noqa: F811 + from ordr.views.registration import registration_form_processing + from ordr.resources.account import RegistrationResource + data = REGISTRATION_FORM_DATA.copy() + data['email'] = 'not an email address' + request = get_post_request(dbsession, data) + context = RegistrationResource(request=request, name=None, parent=None) + result = registration_form_processing(context, request) + assert result['form'].error is not None + + +def test_registration_form_no_create_button(dbsession): # noqa: F811 + from ordr.views.registration import registration_form_processing + from ordr.resources.account import RegistrationResource + data = REGISTRATION_FORM_DATA.copy() + data.pop('create') + request = get_post_request(dbsession, data) + context = RegistrationResource(request=request, name=None, parent=None) + result = registration_form_processing(context, request) + assert result.location == 'http://example.com//'