Browse Source

finished registration form processing

rework
Holger Frey 7 years ago
parent
commit
b19368bc2f
  1. 4
      .gitignore
  2. 11
      development.ini
  3. 3
      ordr/__init__.py
  4. 72
      ordr/events.py
  5. 2
      ordr/resources/helpers.py
  6. 4
      ordr/schemas/account.py
  7. 8
      ordr/scripts/initializedb.py
  8. 25
      ordr/templates/emails/registration.jinja2
  9. 1
      ordr/views/__init__.py
  10. 31
      ordr/views/registration.py
  11. 1
      setup.py
  12. 12
      tests/__init__.py
  13. 36
      tests/events.py
  14. 3
      tests/resources/account.py
  15. 84
      tests/views/registration.py

4
.gitignore vendored

@ -1,6 +1,10 @@
# database # database
ordr.sqlite ordr.sqlite
# helper directories
ordr/templates/deform_origs/
mail/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

11
development.ini

@ -12,6 +12,7 @@ pyramid.debug_notfound = false
pyramid.debug_routematch = false pyramid.debug_routematch = false
pyramid.default_locale_name = en pyramid.default_locale_name = en
pyramid.includes = pyramid.includes =
pyramid_mailer.debug
pyramid_debugtoolbar pyramid_debugtoolbar
pyramid_listing pyramid_listing
@ -32,6 +33,16 @@ passlib.default = argon2
# flag every encryption method as deprecated except the first one # flag every encryption method as deprecated except the first one
passlib.deprecated = auto 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 # By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'. # '127.0.0.1' and '::1'.

3
ordr/__init__.py

@ -20,4 +20,7 @@ def main(global_config, **settings):
config.include('.schemas') config.include('.schemas')
config.include('.security') config.include('.security')
config.include('.views') config.include('.views')
config.scan()
return config.make_wsgi_app() return config.make_wsgi_app()

72
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)

2
ordr/resources/helpers.py

@ -1,7 +1,5 @@
''' Resources (sub) package, used to connect URLs to views ''' ''' Resources (sub) package, used to connect URLs to views '''
from pyramid.security import Allow, Everyone, DENY_ALL
class BaseChildResource: class BaseChildResource:

4
ordr/schemas/account.py

@ -37,6 +37,6 @@ class RegistrationSchema(CSRFSchema):
) )
password = colander.SchemaNode( password = colander.SchemaNode(
colander.String(), colander.String(),
widget=deform.widget.CheckedPasswordWidget() widget=deform.widget.CheckedPasswordWidget(),
validator=colander.Length(min=8)
) )

8
ordr/scripts/initializedb.py

@ -9,6 +9,8 @@ from pyramid.paster import (
from pyramid.scripts.common import parse_vars from pyramid.scripts.common import parse_vars
from urllib.parse import urlparse
from ..models.meta import Base from ..models.meta import Base
from ..models import ( from ..models import (
get_engine, get_engine,
@ -33,6 +35,12 @@ def main(argv=sys.argv):
setup_logging(config_uri) setup_logging(config_uri)
settings = get_appsettings(config_uri, options=options) 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) engine = get_engine(settings)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)

25
ordr/templates/emails/registration.jinja2

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>[ordr] verify your email address</title>
<link href='http://fonts.googleapis.com/css?family=Anton&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
</head>
<body>
<h1>Hi there!</h1>
<p>
Please verify your email address for the account "{{ user.username }}" by following this link
<a href="{{ request.resource_url(context, data.token.hash) }}">{{ request.resource_url(context, data.token.hash) }}</a>
</p>
<p> The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}.
<p class="signature">
Regards,
<br/>
<span class="brand">ordr</span>
</p>
<p class="footprint">
<small>Please don't respont to this email! This is an automatically generated notification by the system.</small>
<a href="http://distractedbysquirrels.com/" target="_blank" title="This software was originally written by Sebastian Sebald." class="icon-dbs"></a>
</p>
</body>
</html>

1
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('static', 'ordr:static', cache_max_age=age)
config.add_static_view('deform', 'deform:static', cache_max_age=age) config.add_static_view('deform', 'deform:static', cache_max_age=age)
config.scan()

31
ordr/views/registration.py

@ -3,7 +3,11 @@ import deform
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config 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( @view_config(
@ -13,11 +17,11 @@ from pyramid.view import view_config
renderer='ordr:templates/account/registration_form.jinja2' renderer='ordr:templates/account/registration_form.jinja2'
) )
def registration_form(context, request): def registration_form(context, request):
''' show registration form '''
form = context.get_registration_form() form = context.get_registration_form()
return {'form': form} return {'form': form}
@view_config( @view_config(
context='ordr.resources.account.RegistrationResource', context='ordr.resources.account.RegistrationResource',
permission='view', permission='view',
@ -25,11 +29,30 @@ def registration_form(context, request):
renderer='ordr:templates/account/registration_form.jinja2' renderer='ordr:templates/account/registration_form.jinja2'
) )
def registration_form_processing(context, request): 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() form = context.get_registration_form()
if 'create' in request.POST:
data = request.POST.items() data = request.POST.items()
try: try:
appstruct = form.validate(data) appstruct = form.validate(data)
except deform.ValidationFailure as e: except deform.ValidationFailure as e:
pass
return {'form': form} 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'))

1
setup.py

@ -18,6 +18,7 @@ requires = [
'pyramid_debugtoolbar', 'pyramid_debugtoolbar',
'pyramid_jinja2', 'pyramid_jinja2',
'pyramid_listing', 'pyramid_listing',
'pyramid_mailer',
'pyramid_retry', 'pyramid_retry',
'pyramid_tm', 'pyramid_tm',
'SQLAlchemy', 'SQLAlchemy',

12
tests/__init__.py

@ -2,6 +2,7 @@ import pytest
import transaction import transaction
from pyramid import testing from pyramid import testing
from pyramid.csrf import get_csrf_token
APP_SETTINGS = { APP_SETTINGS = {
@ -10,7 +11,8 @@ APP_SETTINGS = {
'session.auto_csrf': True, 'session.auto_csrf': True,
'passlib.schemes': 'argon2 bcrypt', 'passlib.schemes': 'argon2 bcrypt',
'passlib.default': 'argon2', 'passlib.default': 'argon2',
'passlib.deprecated': 'auto' 'passlib.deprecated': 'auto',
'mail.default_sender': 'ordr@example.com'
} }
EXAMPLE_USER_DATA = { EXAMPLE_USER_DATA = {
@ -31,6 +33,7 @@ def app_config():
with testing.testConfig(settings=APP_SETTINGS) as config: with testing.testConfig(settings=APP_SETTINGS) as config:
config.include('pyramid_jinja2') config.include('pyramid_jinja2')
config.include('pyramid_listing') config.include('pyramid_listing')
config.include('pyramid_mailer.testing')
yield config yield config
@ -72,3 +75,10 @@ def get_example_user(role):
) )
user.set_password(first_name) user.set_password(first_name)
return user 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)

36
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

3
tests/resources/account.py

@ -9,6 +9,7 @@ def test_registration_acl():
resource = RegistrationResource('some request', 'a name', 'the parent') resource = RegistrationResource('some request', 'a name', 'the parent')
assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL]
def test_registration_get_registration_form(): def test_registration_get_registration_form():
from pyramid.security import Allow, Everyone, DENY_ALL from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import RegistrationResource from ordr.resources.account import RegistrationResource
@ -18,5 +19,5 @@ def test_registration_get_registration_form():
form = resource.get_registration_form() form = resource.get_registration_form()
assert isinstance(form, deform.Form) assert isinstance(form, deform.Form)
assert len(form.buttons) == 2 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' assert form.buttons[1].title == 'Cancel'

84
tests/views/registration.py

@ -1,12 +1,88 @@
import pytest import pytest
import deform
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest 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 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//'