diff --git a/ordr/__init__.py b/ordr/__init__.py
index 2034961..2a89ec2 100644
--- a/ordr/__init__.py
+++ b/ordr/__init__.py
@@ -17,6 +17,7 @@ def main(global_config, **settings):
config.include('pyramid_jinja2')
config.include('.models')
config.include('.resources')
+ config.include('.schemas')
config.include('.security')
config.add_static_view('static', 'static', cache_max_age=3600)
config.scan()
diff --git a/ordr/resources/__init__.py b/ordr/resources/__init__.py
index b395b9a..5f13291 100644
--- a/ordr/resources/__init__.py
+++ b/ordr/resources/__init__.py
@@ -34,7 +34,7 @@ class RootResource:
:raises: KeyError if child resource is not found
'''
map = {
- 'registration': RegistrationResource
+ 'register': RegistrationResource
}
child_class = map[key]
return child_class(request=self.request, name=key, parent=self)
diff --git a/ordr/resources/account.py b/ordr/resources/account.py
index 662504c..cbcb891 100644
--- a/ordr/resources/account.py
+++ b/ordr/resources/account.py
@@ -1,9 +1,12 @@
''' Resources (sub) package, used to connect URLs to views '''
from pyramid.security import Allow, Everyone, DENY_ALL
+from ordr.schemas.account import RegistrationSchema
+from .helpers import BaseChildResource
-class RegistrationResource:
+
+class RegistrationResource(BaseChildResource):
''' The resource for new user registration
:param pyramid.request.Request request: the current request object
@@ -11,19 +14,16 @@ class RegistrationResource:
:param parent: the parent resouce
'''
- nav_active = 'welcome'
-
- def __init__(self, request, name, parent):
- ''' Create registration resource
-
- :param pyramid.request.Request request: the current request object
- :param str name: the name of the resource
- :param parent: the parent resouce
- '''
- self.request = request
- self.__name__ = name
- self.__parent__ = parent
+ nav_active = 'registration'
def __acl__(self):
''' access controll list for the resource '''
return [(Allow, Everyone, 'view'), DENY_ALL]
+
+ def get_registration_form(self, **override):
+ ''' returns the registration form'''
+ settings = {
+ 'buttons': ('Create account', 'Cancel'),
+ }
+ settings.update(override)
+ return self._prepare_form(RegistrationSchema, **settings)
diff --git a/ordr/resources/helpers.py b/ordr/resources/helpers.py
new file mode 100644
index 0000000..9efb2df
--- /dev/null
+++ b/ordr/resources/helpers.py
@@ -0,0 +1,28 @@
+''' Resources (sub) package, used to connect URLs to views '''
+
+from pyramid.security import Allow, Everyone, DENY_ALL
+
+
+class BaseChildResource:
+
+ def __init__(self, request, name, parent):
+ ''' Create a child resource
+
+ :param pyramid.request.Request request: the current request object
+ :param str name: the name of the resource
+ :param parent: the parent resouce
+ '''
+ self.request = request
+ self.__name__ = name
+ self.__parent__ = parent
+
+ def __acl__(self):
+ ''' access controll list for the resource '''
+ raise NotImplementedError()
+
+ def _prepare_form(self, schema, prefill=None, **settings):
+ ''' prepares a deform form for the resource'''
+ form = schema.as_form(self.request, **settings)
+ if prefill is not None:
+ form.set_appstruct(prefill)
+ return form
diff --git a/ordr/schemas/__init__.py b/ordr/schemas/__init__.py
new file mode 100644
index 0000000..7989d24
--- /dev/null
+++ b/ordr/schemas/__init__.py
@@ -0,0 +1,53 @@
+''' Schemas (sub) package, for form rendering and validation '''
+
+import colander
+import deform
+
+from deform.renderer import configure_zpt_renderer
+
+from .helpers import (
+ deferred_csrf_default,
+ deferred_csrf_validator
+ )
+
+
+# Base Schema
+
+class CSRFSchema(colander.Schema):
+ ''' base class for schemas with csrf validation '''
+
+ csrf_token = colander.SchemaNode(
+ colander.String(),
+ default=deferred_csrf_default,
+ validator=deferred_csrf_validator,
+ widget=deform.widget.HiddenWidget(),
+ )
+
+ @classmethod
+ def as_form(cls, request, url=None, **kwargs):
+ ''' returns the schema as a form
+
+ :param pyramid.request.Request request: the current request
+ :param str url:
+ form action url,
+ url is not set, the current context and view name will be used to
+ constuct a url for the form
+ :param kwargs:
+ additional parameters for the form rendering.
+ '''
+ if url is None:
+ url = request.resource_url(request.context, request.view_name)
+ schema = cls().bind(request=request)
+ form = deform.Form(schema, action=url, **kwargs)
+ return form
+
+
+def includeme(config):
+ '''
+ Initialize the form schemas
+
+ Activate this setup using ``config.include('ordr.schemas')``.
+
+ '''
+ # Make Deform widgets aware of our widget template paths
+ configure_zpt_renderer(['ordr:templates/deform'])
diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py
new file mode 100644
index 0000000..e1eb3a4
--- /dev/null
+++ b/ordr/schemas/account.py
@@ -0,0 +1,36 @@
+import colander
+import deform
+
+
+from . import CSRFSchema
+from .helpers import (
+ deferred_unique_email_validator,
+ deferred_unique_username_validator,
+ )
+
+
+# schema for user registration
+
+class RegistrationSchema(CSRFSchema):
+ ''' new user registration '''
+
+ username = colander.SchemaNode(
+ colander.String(),
+ widget=deform.widget.TextInputWidget(readonly=True),
+ description='automagically generated for you',
+ validator=deferred_unique_username_validator,
+ )
+ first_name = colander.SchemaNode(
+ colander.String()
+ )
+ last_name = colander.SchemaNode(
+ colander.String()
+ )
+ email = colander.SchemaNode(
+ colander.String(),
+ validator=deferred_unique_email_validator
+ )
+ password = colander.SchemaNode(
+ colander.String(),
+ widget=deform.widget.CheckedPasswordWidget()
+ )
diff --git a/ordr/schemas/helpers.py b/ordr/schemas/helpers.py
new file mode 100644
index 0000000..efa1798
--- /dev/null
+++ b/ordr/schemas/helpers.py
@@ -0,0 +1,63 @@
+''' helper functions for schemas '''
+
+import colander
+
+from pyramid.csrf import get_csrf_token, check_csrf_token
+
+from ordr.models import User
+
+
+@colander.deferred
+def deferred_csrf_default(node, kw):
+ ''' sets the current csrf token '''
+ request = kw.get('request')
+ return get_csrf_token(request)
+
+
+@colander.deferred
+def deferred_csrf_validator(node, kw):
+ ''' validates a submitted csrf token '''
+ def validate_csrf(node, value):
+ request = kw.get('request')
+ if not check_csrf_token(request, raises=False):
+ raise colander.Invalid(node, 'Bad CSRF token')
+ return validate_csrf
+
+
+@colander.deferred
+def deferred_unique_username_validator(node, kw):
+ ''' checks if an username is not registered already '''
+
+ def validate_unique_username(node, value):
+ request = kw.get('request')
+ user = request.dbsession.query(User).filter_by(username=value).first()
+ if user is not None:
+ raise colander.Invalid(node, 'User name already registered')
+ return validate_unique_username
+
+
+@colander.deferred
+def deferred_unique_email_validator(node, kw):
+ ''' checks if an email is not registered already '''
+ email_validator = colander.Email()
+
+ def validate_unique_email(node, value):
+ email_validator(node, value) # raises exception on invalid address
+ request = kw.get('request')
+ user = request.dbsession.query(User).filter_by(email=value).first()
+ if user not in (None, request.context.model):
+ # allow existing email addresses if
+ # it belongs to the user that is currently edited
+ raise colander.Invalid(node, 'Email address in use')
+ return validate_unique_email
+
+
+@colander.deferred
+def deferred_password_validator(node, kw):
+ ''' checks password confirmation for settings '''
+
+ def validate_password_confirmation(node, value):
+ request = kw.get('request')
+ if request.user is None or not request.user.check_password(value):
+ raise colander.Invalid(node, 'Wrong password')
+ return validate_password_confirmation
diff --git a/ordr/templates/account/registration_form.jinja2 b/ordr/templates/account/registration_form.jinja2
new file mode 100644
index 0000000..806a9d9
--- /dev/null
+++ b/ordr/templates/account/registration_form.jinja2
@@ -0,0 +1,5 @@
+{% extends "ordr:templates/layout.jinja2" %}
+
+{% block content %}
+ {{ context.get_registration_form().render()|safe }}
+{% endblock content %}
diff --git a/ordr/templates/layout.jinja2 b/ordr/templates/layout.jinja2
index 35c0138..617cda6 100644
--- a/ordr/templates/layout.jinja2
+++ b/ordr/templates/layout.jinja2
@@ -30,7 +30,7 @@
FAQs
-
+
Register
diff --git a/ordr/views/registration.py b/ordr/views/registration.py
new file mode 100644
index 0000000..232de9f
--- /dev/null
+++ b/ordr/views/registration.py
@@ -0,0 +1,14 @@
+# from pyramid.httpexceptions import HTTPFound
+from pyramid.view import view_config
+
+# from ordr.models import User
+
+
+@view_config(
+ context='ordr.resources.account.RegistrationResource',
+ permission='view',
+ request_method='GET',
+ renderer='ordr:templates/account/registration_form.jinja2'
+ )
+def registration_form(context, request):
+ return {}
diff --git a/setup.py b/setup.py
index c8b4fe2..98c1526 100644
--- a/setup.py
+++ b/setup.py
@@ -11,6 +11,7 @@ with open(os.path.join(here, 'CHANGES.txt')) as f:
requires = [
'argon2_cffi',
'bcrypt',
+ 'deform',
'passlib',
'plaster_pastedeploy',
'pyramid >= 1.9a',
diff --git a/tests/_functional/registration.py b/tests/_functional/registration.py
new file mode 100644
index 0000000..44b5c07
--- /dev/null
+++ b/tests/_functional/registration.py
@@ -0,0 +1,9 @@
+''' functional tests for ordr2.views.registration '''
+
+from . import testappsetup, testapp # noqa: F401
+
+
+def test_registration_form(testapp): # noqa: F811
+ result = testapp.get('/register')
+ active = result.html.find('li', class_='active')
+ assert active.a['href'] == '/register'
diff --git a/tests/resources/account.py b/tests/resources/account.py
index 03c6cce..1fba2f0 100644
--- a/tests/resources/account.py
+++ b/tests/resources/account.py
@@ -1,20 +1,22 @@
''' Tests for the account resources '''
-
-def test_registration_init():
- from ordr.resources.account import RegistrationResource
- resource = RegistrationResource(
- request='some request',
- name='a name',
- parent='the parent'
- )
- assert resource.__name__ == 'a name'
- assert resource.__parent__ == 'the parent'
- assert resource.request == 'some request'
-
+from pyramid.testing import DummyRequest
+
def test_registration_acl():
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import RegistrationResource
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
+ import deform
+ request = DummyRequest()
+ resource = RegistrationResource(request, 'a name', 'the parent')
+ 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[1].title == 'Cancel'
diff --git a/tests/resources/base_child_resource.py b/tests/resources/base_child_resource.py
new file mode 100644
index 0000000..94753ca
--- /dev/null
+++ b/tests/resources/base_child_resource.py
@@ -0,0 +1,65 @@
+''' Tests for the root resource '''
+
+import pytest
+
+from pyramid.testing import DummyRequest, DummyResource
+
+
+def test_base_child_init():
+ from ordr.resources.helpers import BaseChildResource
+ resource = BaseChildResource(
+ request='some request',
+ name='a name',
+ parent='the parent'
+ )
+ assert resource.__name__ == 'a name'
+ assert resource.__parent__ == 'the parent'
+ assert resource.request == 'some request'
+
+
+def test_base_child_acl():
+ from ordr.resources.helpers import BaseChildResource
+ resource = BaseChildResource(
+ request='some request',
+ name='a name',
+ parent='the parent'
+ )
+ with pytest.raises(NotImplementedError):
+ resource.__acl__()
+
+
+def test_base_child_prepare_form():
+ from ordr.resources.helpers import BaseChildResource
+ from ordr.schemas.account import RegistrationSchema
+ import deform
+ parent = DummyResource()
+ request = DummyRequest()
+ resource = BaseChildResource(request, 'a name', parent)
+ form = resource._prepare_form(RegistrationSchema)
+ assert isinstance(form, deform.Form)
+ assert form.action == 'http://example.com//'
+ assert len(form.buttons) == 0
+
+
+def test_base_child_prepare_form_url():
+ from ordr.resources.helpers import BaseChildResource
+ from ordr.schemas.account import RegistrationSchema
+ parent = DummyResource()
+ request = DummyRequest()
+ resource = BaseChildResource(request, 'a name', parent)
+ form = resource._prepare_form(RegistrationSchema, url='/foo')
+ assert form.action == '/foo'
+
+
+def test_base_child_prepare_form_settings():
+ from ordr.resources.helpers import BaseChildResource
+ from ordr.schemas.account import RegistrationSchema
+ import deform
+ parent = DummyResource()
+ request = DummyRequest()
+ resource = BaseChildResource(request, 'a name', parent)
+ settings = {'buttons': ('ok', 'cancel')}
+ form = resource._prepare_form(RegistrationSchema, **settings)
+ assert len(form.buttons) == 2
+ assert isinstance(form.buttons[0], deform.Button)
+ assert isinstance(form.buttons[1], deform.Button)
diff --git a/tests/resources/root.py b/tests/resources/root.py
index 4926532..8c6ff63 100644
--- a/tests/resources/root.py
+++ b/tests/resources/root.py
@@ -22,9 +22,9 @@ def test_root_getitem():
from ordr.resources import RootResource
from ordr.resources.account import RegistrationResource
root = RootResource(None)
- child = root['registration']
+ child = root['register']
assert isinstance(child, RegistrationResource)
- assert child.__name__ == 'registration'
+ assert child.__name__ == 'register'
assert child.__parent__ == root
assert child.request == root.request
diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py
new file mode 100644
index 0000000..2257fd7
--- /dev/null
+++ b/tests/schemas/__init__.py
@@ -0,0 +1,28 @@
+''' Test package for ordr.schemas '''
+
+from pyramid.testing import DummyRequest, DummyResource
+
+
+def test_csrf_schema_form_with_custom_url():
+ ''' test for creation with custom url '''
+ from ordr.schemas import CSRFSchema
+
+ request = DummyRequest()
+ form = CSRFSchema.as_form(request, url='/Nudge/Nudge')
+
+ assert form.action == '/Nudge/Nudge'
+ assert form.buttons == []
+
+
+def test_csrf_schema_form_with_automatic_url():
+ ''' test for creation with custom url '''
+ from ordr.schemas import CSRFSchema
+ root = DummyResource()
+ context = DummyResource('Crunchy', root)
+
+ request = DummyRequest(context=context, view_name='Frog')
+ form = CSRFSchema.as_form(request, buttons=['submit'])
+
+ assert 'http://example.com/Crunchy/Frog' == form.action
+ assert len(form.buttons) == 1
+ assert form.buttons[0].type == 'submit'
diff --git a/tests/schemas/helpers.py b/tests/schemas/helpers.py
new file mode 100644
index 0000000..d27d808
--- /dev/null
+++ b/tests/schemas/helpers.py
@@ -0,0 +1,165 @@
+''' Tests for ordr.schemas.helpers '''
+
+import pytest
+
+from pyramid.testing import DummyRequest, DummyResource
+
+from .. import app_config, dbsession, get_example_user # noqa: F401
+
+
+def test_deferred_csrf_default():
+ ''' deferred_csrf_default should return a csrf token '''
+ from ordr.schemas.helpers import deferred_csrf_default
+ from pyramid.csrf import get_csrf_token
+
+ request = DummyRequest()
+ token = deferred_csrf_default(None, {'request': request})
+
+ assert token == get_csrf_token(request)
+
+
+def test_deferred_csrf_validator_ok():
+ ''' test deferred_csrf_validator with valid csrf token '''
+ from ordr.schemas.helpers import deferred_csrf_validator
+ from pyramid.csrf import get_csrf_token
+
+ request = DummyRequest()
+ token = get_csrf_token(request)
+ request.POST = {'csrf_token': token}
+ validation_func = deferred_csrf_validator(None, {'request': request})
+
+ assert validation_func(None, None) is None
+
+
+@pytest.mark.parametrize('post', [{}, {'csrf_token': 'Albatross!'}])
+def test_deferred_csrf_validator_fails_on_no_csrf_token(post):
+ ''' test deferred_csrf_validator with invalid or missing csrf token '''
+ from ordr.schemas.helpers import deferred_csrf_validator
+ from colander import Invalid
+
+ request = DummyRequest()
+ request.POST = post
+ validation_func = deferred_csrf_validator(None, {'request': request})
+
+ with pytest.raises(Invalid):
+ assert validation_func(None, None) is None
+
+
+def test_deferred_unique_username_validator_ok(dbsession): # noqa: F811
+ ''' unknown usernames should not raise an invalidation error '''
+ from ordr.schemas.helpers import deferred_unique_username_validator
+ from ordr.models.account import Role
+
+ request = DummyRequest(dbsession=dbsession)
+ user = get_example_user(Role.USER)
+ dbsession.add(user)
+ validation_func = deferred_unique_username_validator(
+ None,
+ {'request': request}
+ )
+
+ assert validation_func(None, 'AnneElk') is None
+
+
+def test_deferred_unique_username_validator_fails(dbsession): # noqa: F811
+ ''' known username should raise an invalidation error '''
+ from ordr.schemas.helpers import deferred_unique_username_validator
+ from ordr.models.account import Role
+ from colander import Invalid
+
+ request = DummyRequest(dbsession=dbsession)
+ user = get_example_user(Role.USER)
+ dbsession.add(user)
+ validation_func = deferred_unique_username_validator(
+ None,
+ {'request': request}
+ )
+
+ with pytest.raises(Invalid):
+ assert validation_func(None, 'TerryGilliam') is None
+
+
+def test_deferred_unique_email_validator_ok(dbsession): # noqa: F811
+ ''' unknown emails should not raise an invalidation error '''
+ from ordr.schemas.helpers import deferred_unique_email_validator
+ from ordr.models.account import Role
+
+ context = DummyResource(model=None)
+ request = DummyRequest(dbsession=dbsession, context=context)
+ user = get_example_user(Role.USER)
+ dbsession.add(user)
+ validation_func = deferred_unique_email_validator(
+ None,
+ {'request': request}
+ )
+
+ assert validation_func(None, 'elk@example.com') is None
+
+
+def test_deferred_unique_email_validator_ok_same_user(dbsession): # noqa: F811
+ ''' known emails of a user might not raise an error
+
+ if a user is edited and the mail address is not change, no invalidation
+ error should be raised
+ '''
+ from ordr.schemas.helpers import deferred_unique_email_validator
+ from ordr.models.account import Role
+
+ user = get_example_user(Role.USER)
+ context = DummyResource(model=user)
+ request = DummyRequest(dbsession=dbsession, context=context)
+ dbsession.add(user)
+ validation_func = deferred_unique_email_validator(
+ None,
+ {'request': request}
+ )
+
+ assert validation_func(None, user.email) is None
+
+
+@pytest.mark.parametrize( # noqa: F811
+ 'email', ['', 'gilliam@example.com', 'malformed']
+ )
+def test_deferred_unique_email_validator_fails(dbsession, email):
+ ''' known, empty or malformed emails should raise an invalidation error '''
+ from ordr.schemas.helpers import deferred_unique_email_validator
+ from ordr.models.account import Role
+ from colander import Invalid
+
+ context = DummyResource(model=None)
+ request = DummyRequest(dbsession=dbsession, context=context)
+ user = get_example_user(Role.USER)
+ dbsession.add(user)
+ validation_func = deferred_unique_email_validator(
+ None,
+ {'request': request}
+ )
+
+ with pytest.raises(Invalid):
+ assert validation_func(None, email) is None
+
+
+def test_deferred_password_validator_ok():
+ ''' correct password should not raise invalidation error '''
+ from ordr.schemas.helpers import deferred_password_validator
+ from ordr.models.account import Role
+
+ user = get_example_user(Role.USER)
+ request = DummyRequest(user=user)
+ validation_func = deferred_password_validator(None, {'request': request})
+
+ assert validation_func(None, 'Terry') is None
+
+
+def test_deferred_password_validator_fails():
+ ''' incorrect password should raise invalidation error '''
+ from ordr.schemas.helpers import deferred_password_validator
+ from ordr.models.account import Role
+ from colander import Invalid
+
+ user = get_example_user(Role.USER)
+ request = DummyRequest(user=user)
+ validation_func = deferred_password_validator(None, {'request': request})
+
+ with pytest.raises(Invalid):
+ assert validation_func(None, 'Wrong Password') is None
diff --git a/tests/views/registration.py b/tests/views/registration.py
new file mode 100644
index 0000000..255543c
--- /dev/null
+++ b/tests/views/registration.py
@@ -0,0 +1,12 @@
+import pytest
+
+from pyramid.httpexceptions import HTTPFound
+from pyramid.testing import DummyRequest
+
+from .. import app_config, dbsession # noqa: F401
+
+
+def test_faq():
+ from ordr.views.registration import registration_form
+ result = registration_form(None, None)
+ assert result == {}