Compare commits

...
This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.

38 Commits

Author SHA1 Message Date
Holger Frey f8d6d475d1 refactored account resource and view 7 years ago
Holger Frey 5cfd68e85e added account settings and change password 7 years ago
Holger Frey 0a2f7a5832 completed password reset process 7 years ago
Holger Frey 5d79bd34d6 working on forgotten password process 7 years ago
Holger Frey 8dbf43ea99 ignoring scripts for simple coverage 7 years ago
Holger Frey 7668ecfc88 added error view for expired token 7 years ago
Holger Frey 8503f27c66 linting and documentation 7 years ago
Holger Frey 1c96f9b970 finished registration workflow 7 years ago
Holger Frey b19368bc2f finished registration form processing 7 years ago
Holger Frey de2dbd352a completet registration form 7 years ago
Holger Frey 494957aeba working on the first form 7 years ago
Holger Frey 329c268e37 added test for prefilling of forms 7 years ago
Holger Frey f63e337dde added schemas, starting on registration 7 years ago
Holger Frey bbf89ad3a5 added registration resource 7 years ago
Holger Frey 20032eede1 linting applied 7 years ago
Holger Frey c8c0c4678a added login, logout 7 years ago
Holger Frey 551cc260e2 added session 7 years ago
Holger Frey 65ad7738ff webroot redirects to login or orders if user is logged in 7 years ago
Holger Frey 53f36e8566 added main navigation to layout template 7 years ago
Holger Frey 0d83eac6c1 moved tests outside package 7 years ago
Holger Frey a21612cf0e cleanup after linting 7 years ago
Holger Frey 8f80a7ce37 added authorization and authentification 7 years ago
Holger Frey 5f7f26b3b6 added models for users and tokens 7 years ago
Holger Frey 2be8692625 added model for account roles 7 years ago
Holger Frey a0b50a81d6 added custom column type JsonEncoder 7 years ago
Holger Frey 8b2c6a1e24 new make command for coverage 7 years ago
Holger Frey 3b9246633a added tests for root resource and security module 7 years ago
Holger Frey 9cc02f5e4e fixed a bug constructing crypt context config 7 years ago
Holger Frey 04fea4e7cd included passlib for password hashing 7 years ago
Holger Frey 93dc403dd6 added pages 7 years ago
Holger Frey bd8e3557aa cleanup of views and templates 7 years ago
Holger Frey d154d74c4a added resource sub package 7 years ago
Holger Frey 039e1d111d cleanup of templates and css 7 years ago
Holger Frey 8b1441dd34 changed from unittest to py.test 7 years ago
Holger Frey 5e7702cf04 added Makefile and others 7 years ago
Holger Frey fb0dab46a4 changed routing to traversal 7 years ago
Holger Frey a93b5239e5 added pyramid_listing to requiered packages 7 years ago
Holger Frey fd712e4e5a initial import of pyramid project 7 years ago
  1. 3
      .coveragerc
  2. 10
      .gitignore
  3. 4
      CHANGES.txt
  4. 2
      MANIFEST.in
  5. 80
      Makefile
  6. 13
      Pipfile
  7. 36
      README.txt
  8. 97
      development.ini
  9. 25
      ordr/__init__.py
  10. 85
      ordr/events.py
  11. 75
      ordr/models/__init__.py
  12. 234
      ordr/models/account.py
  13. 39
      ordr/models/meta.py
  14. 49
      ordr/resources/__init__.py
  15. 206
      ordr/resources/account.py
  16. 27
      ordr/resources/helpers.py
  17. 53
      ordr/schemas/__init__.py
  18. 101
      ordr/schemas/account.py
  19. 64
      ordr/schemas/validators.py
  20. 1
      ordr/scripts/__init__.py
  21. 60
      ordr/scripts/initializedb.py
  22. 97
      ordr/security.py
  23. BIN
      ordr/static/pyramid-16x16.png
  24. 21
      ordr/static/scripts.js
  25. 0
      ordr/static/style.css
  26. 33
      ordr/templates/account/forgotten_password_completed.jinja2
  27. 49
      ordr/templates/account/forgotten_password_form.jinja2
  28. 32
      ordr/templates/account/forgotten_password_reset.jinja2
  29. 33
      ordr/templates/account/forgotten_password_verify.jinja2
  30. 41
      ordr/templates/account/login.jinja2
  31. 16
      ordr/templates/account/password_changed.jinja2
  32. 14
      ordr/templates/account/password_form.jinja2
  33. 36
      ordr/templates/account/registration_completed.jinja2
  34. 33
      ordr/templates/account/registration_form.jinja2
  35. 35
      ordr/templates/account/registration_verify.jinja2
  36. 14
      ordr/templates/account/settings_form.jinja2
  37. 16
      ordr/templates/account/settings_mail_changed.jinja2
  38. 46
      ordr/templates/deform/checked_password.pt
  39. 110
      ordr/templates/deform/form.pt
  40. 55
      ordr/templates/deform/mapping_item.pt
  41. 18
      ordr/templates/deform/password.pt
  42. 17
      ordr/templates/deform/readonly/textinput.pt
  43. 18
      ordr/templates/deform/textinput.pt
  44. 25
      ordr/templates/emails/email_change.jinja2
  45. 25
      ordr/templates/emails/password_reset.jinja2
  46. 25
      ordr/templates/emails/registration.jinja2
  47. 14
      ordr/templates/errors/404_file_not_found.jinja2
  48. 14
      ordr/templates/errors/410_token_expiry.jinja2
  49. 82
      ordr/templates/layout.jinja2
  50. 8
      ordr/templates/pages/faq.jinja2
  51. 14
      ordr/views/__init__.py
  52. 402
      ordr/views/account.py
  53. 22
      ordr/views/errors.py
  54. 26
      ordr/views/pages.py
  55. 65
      production.ini
  56. 8
      requirements_dev.txt
  57. 30
      setup.cfg
  58. 66
      setup.py
  59. 89
      tests/__init__.py
  60. 92
      tests/_functional/__init__.py
  61. 10
      tests/_functional/account/__init__.py
  62. 89
      tests/_functional/account/forgotten_password.py
  63. 65
      tests/_functional/account/login_logout.py
  64. 58
      tests/_functional/account/registration.py
  65. 125
      tests/_functional/account/settings.py
  66. 9
      tests/_functional/errors.py
  67. 57
      tests/_functional/layout.py
  68. 21
      tests/_functional/pages.py
  69. 52
      tests/events.py
  70. 1
      tests/models/__init__.py
  71. 255
      tests/models/account.py
  72. 38
      tests/models/meta.py
  73. 1
      tests/resources/__init__.py
  74. 323
      tests/resources/account.py
  75. 94
      tests/resources/base_child_resource.py
  76. 48
      tests/resources/root.py
  77. 28
      tests/schemas/__init__.py
  78. 165
      tests/schemas/validators.py
  79. 132
      tests/security.py
  80. 1
      tests/views/__init__.py
  81. 22
      tests/views/account/__init__.py
  82. 241
      tests/views/account/forgotten_password.py
  83. 95
      tests/views/account/login_logout.py
  84. 131
      tests/views/account/registration.py
  85. 300
      tests/views/account/settings.py
  86. 23
      tests/views/errors.py
  87. 29
      tests/views/pages.py

3
.coveragerc

@ -0,0 +1,3 @@
[run]
source = ordr
omit = ordr/test*

10
.gitignore vendored

@ -1,3 +1,10 @@
# database
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]
@ -35,6 +42,7 @@ pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Unit test / coverage reports
.pytest_cache
htmlcov/ htmlcov/
.tox/ .tox/
.coverage .coverage
@ -60,4 +68,4 @@ target/
# pyenv python configuration file # pyenv python configuration file
.python-version .python-version
venv-ordr2 .venv

4
CHANGES.txt

@ -0,0 +1,4 @@
0.0
---
- Initial version.

2
MANIFEST.in

@ -0,0 +1,2 @@
include *.txt *.ini *.cfg *.rst
recursive-include ordr *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2

80
Makefile

@ -0,0 +1,80 @@
.PHONY: clean clean-test clean-pyc clean-build help
.DEFAULT_GOAL := help
define BROWSER_PYSCRIPT
import os, webbrowser, sys
try:
from urllib import pathname2url
except:
from urllib.request import pathname2url
webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
endef
export BROWSER_PYSCRIPT
define PRINT_HELP_PYSCRIPT
import re, sys
for line in sys.stdin:
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
if match:
target, help = match.groups()
print("%-20s %s" % (target, help))
endef
export PRINT_HELP_PYSCRIPT
BROWSER := python -c "$$BROWSER_PYSCRIPT"
help:
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
clean-build: ## remove build artifacts
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +
clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
clean-test: ## remove test and coverage artifacts
rm -fr .tox/
rm -f .coverage
rm -fr htmlcov/
lint: ## check style with flake8
flake8 ordr tests
test: ## run tests quickly with the default Python, ignoring functional tests
py.test -x
coverage: ## check code coverage quickly with the default Python
coverage run --source ordr -m pytest --ignore tests/_functional/
coverage report -m --omit=ordr/scripts/*
coverage html --omit=ordr/scripts/*
$(BROWSER) htmlcov/index.html
fcoverage: ## check code coverage with the default Python and functional tests
coverage run --source ordr -m pytest
coverage report -m
coverage html
$(BROWSER) htmlcov/index.html
release: clean ## package and upload a release
python setup.py sdist upload
python setup.py bdist_wheel upload
dist: clean ## builds source and wheel package
python setup.py sdist
python setup.py bdist_wheel
ls -l dist
install: clean ## install the package to the active Python's site-packages
python setup.py install

13
Pipfile

@ -0,0 +1,13 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]

36
README.txt

@ -0,0 +1,36 @@
Ordr
====
This is a complete redo of the Ordr project.
Getting Started
---------------
- Change directory into your newly created project.
cd ordr
- Create a Python virtual environment.
python3 -m venv env
- Upgrade packaging tools.
env/bin/pip install --upgrade pip setuptools
- Install the project in editable mode with its testing requirements.
env/bin/pip install -e ".[testing]"
- Configure the database.
env/bin/initialize_ordr_db development.ini
- Run your project's tests.
env/bin/pytest
- Run your project.
env/bin/pserve development.ini

97
development.ini

@ -0,0 +1,97 @@
###
# app configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
###
[app:main]
use = egg:ordr
pyramid.reload_templates = true
pyramid.debug_authorization = false
pyramid.debug_notfound = false
pyramid.debug_routematch = false
pyramid.default_locale_name = en
pyramid.includes =
pyramid_mailer.debug
pyramid_debugtoolbar
pyramid_listing
sqlalchemy.url = sqlite:///%(here)s/ordr.sqlite
retry.attempts = 3
auth.secret = 'Change Me 1'
session.secret = 'Change Me 2'
session.auto_csrf = true
static_views.cache_max_age = 0
# passlib settings
# setup the context to support only argon2 for the moment
passlib.schemes = argon2 bcrypt
# default encryption scheme is argon2
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'.
# debugtoolbar.hosts = 127.0.0.1 ::1
###
# wsgi server configuration
###
[server:main]
use = egg:waitress#main
listen = localhost:6543
###
# logging configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
###
[loggers]
keys = root, ordr, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_ordr]
level = DEBUG
handlers =
qualname = ordr
[logger_sqlalchemy]
level = INFO
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

25
ordr/__init__.py

@ -0,0 +1,25 @@
from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory
__version__ = '0.0.1'
def main(global_config, **settings): # pragma: no cover
''' This function returns a Pyramid WSGI application. '''
config = Configurator(settings=settings)
session_factory = SignedCookieSessionFactory(settings['session.secret'])
config.set_session_factory(session_factory)
config.set_default_csrf_options(require_csrf=settings['session.auto_csrf'])
config.include('pyramid_jinja2')
config.include('.models')
config.include('.resources')
config.include('.schemas')
config.include('.security')
config.include('.views')
config.scan()
return config.make_wsgi_app()

85
ordr/events.py

@ -0,0 +1,85 @@
''' 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
:param str send_to: optional email address overriding user's email address
'''
#: subject of the notification
subject = None
#: template to render
template = 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):
''' user notification for account activation '''
subject = '[ordr] Your account was activated'
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 '''
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.send_to],
html=body
)
mailer = get_mailer(event.request.registry)
mailer.send(message)

75
ordr/models/__init__.py

@ -0,0 +1,75 @@
from sqlalchemy import engine_from_config
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import configure_mappers
import zope.sqlalchemy
# import or define all models here to ensure they are attached to the
# Base.metadata prior to any initialization routines
from .account import Role, Token, TokenSubject, User # flake8: noqa
# run configure_mappers after defining all of the models to ensure
# all relationships can be setup
configure_mappers()
def get_engine(settings, prefix='sqlalchemy.'):
return engine_from_config(settings, prefix)
def get_session_factory(engine):
factory = sessionmaker()
factory.configure(bind=engine)
return factory
def get_tm_session(session_factory, transaction_manager):
'''
Get a ``sqlalchemy.orm.Session`` instance backed by a transaction.
This function will hook the session to the transaction manager which
will take care of committing any changes.
- When using pyramid_tm it will automatically be committed or aborted
depending on whether an exception is raised.
- When using scripts you should wrap the session in a manager yourself.
For example::
import transaction
engine = get_engine(settings)
session_factory = get_session_factory(engine)
with transaction.manager:
dbsession = get_tm_session(session_factory, transaction.manager)
'''
dbsession = session_factory()
zope.sqlalchemy.register(
dbsession, transaction_manager=transaction_manager)
return dbsession
def includeme(config): # pragma: no cover
'''
Initialize the model for a Pyramid app.
Activate this setup using ``config.include('ordr.models')``.
'''
settings = config.get_settings()
settings['tm.manager_hook'] = 'pyramid_tm.explicit_manager'
# use pyramid_tm to hook the transaction lifecycle to the request
config.include('pyramid_tm')
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
session_factory = get_session_factory(get_engine(settings))
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
config.add_request_method(
# r.tm is the transaction manager used by pyramid_tm
lambda r: get_tm_session(session_factory, r.tm),
'dbsession',
reify=True
)

234
ordr/models/account.py

@ -0,0 +1,234 @@
''' Models for User Accounts and Roles '''
import enum
import uuid
from datetime import datetime, timedelta
from pyramid import httpexceptions
from sqlalchemy import (
Column,
Date,
DateTime,
Enum,
ForeignKey,
Integer,
Text,
Unicode
)
from sqlalchemy.orm import relationship
from .meta import Base, JsonEncoder
# custom exceptions
class TokenExpired(httpexceptions.HTTPGone):
pass
# enumerations
class Role(enum.Enum):
''' roles of user accounts '''
UNVALIDATED = 'unvalidated' #: new user, email not validated
NEW = 'new' #: new user, email validated, not active
USER = 'user' #: standard user, may place and view orders
PURCHASER = 'purchaser' #: privileged user, may edit orders
ADMIN = 'admin' #: fully privileged user
INACTIVE = 'inactive' #: user that is no longer active ("deleted")
@property
def principal(self):
''' returns the principal identifier of the role '''
return 'role:' + self.name.lower()
def __str__(self):
''' string representation '''
return self.name.capitalize()
class TokenSubject(enum.Enum):
''' Email Token Subjects for user accounts '''
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
class User(Base):
''' A user of the application '''
__tablename__ = 'users'
#: primary key
id = Column(Integer, primary_key=True)
#: unique user name
username = Column(Text, nullable=False, unique=True)
#: hashed password, see :mod:`ordr.security`
password_hash = Column(Text, nullable=False)
#: role of the user, see :class:`ordr.models.account.Role`
role = Column(Enum(Role), nullable=False)
first_name = Column(Text, nullable=False)
last_name = Column(Text, nullable=False)
email = Column(Text, nullable=False, unique=True)
date_created = Column(Date, nullable=False, default=datetime.utcnow)
#: tokens for new user registration and forgotten passwords
tokens = relationship(
'Token',
back_populates='owner',
cascade="all, delete-orphan"
)
@property
def principal(self):
''' returns the principal identifier for the user '''
return 'user:{}'.format(self.id)
@property
def principals(self):
''' returns all principal identifiers for the user including roles '''
principals = [self.principal, self.role.principal]
if self.role is Role.PURCHASER:
# a purchaser is also a user
principals.append(Role.USER.principal)
elif self.role is Role.ADMIN:
# an admin is also a purchaser and a user
principals.append(Role.PURCHASER.principal)
principals.append(Role.USER.principal)
return principals
@property
def is_active(self):
''' is true if the user has an active role '''
return self.role in {Role.USER, Role.PURCHASER, Role.ADMIN}
def set_password(self, password):
''' hashes a password using :mod:`ordr.security.passlib_context` '''
from ordr.security import password_context
self.password_hash = password_context.hash(password)
def check_password(self, password):
''' checks a password against a stored password hash
if the password algorithm is considered deprecated, the stored hash
will be updated using the current algorithm
'''
from ordr.security import password_context
ok, new_hash = password_context.verify_and_update(
password,
self.password_hash
)
if not ok:
# password does not match, return False
return False
elif new_hash:
# algorithm is deprecated, update hash with new algorithm
self.password_hash = new_hash
# password match, return True
return True
def issue_token(self, request, subject, payload=None):
''' issues a token for mail change, password reset or user verification
:param pyramid.request.Request request: the current request object
:param ordr.models.account.TokenSubject subject: kind of token
:param payload: extra data to store with the token, JSON serializable
:rtype: (str) unique hash to access the token
'''
return Token.issue(request, self, subject, payload)
def __str__(self):
''' string representation '''
return str(self.username)
class Token(Base):
''' Tokens for mail change, account verification and password reset '''
__tablename__ = 'tokens'
#: hash identifyer of the token
hash = Column(Unicode, primary_key=True)
#: :class:`ordr.models.account.TokenSubject`
subject = Column(Enum(TokenSubject), nullable=False)
#: token expires at this date and time
expires = Column(DateTime, nullable=False)
#: additional data to attach to a token
payload = Column(JsonEncoder, nullable=True)
#: the user_id the token belongs to
owner_id = Column(Integer, ForeignKey('users.id'))
#: the user the token belongs to
owner = relationship('User', back_populates='tokens')
@classmethod
def issue(cls, request, owner, subject, payload=None):
''' issues a token for password reset or user verification
if the expiry keys for the token is not set in the app configuration,
the token will expire in five minutes.
to set the expiry time in the conig use `token_expiry.` prefix followed
by the lowercase name of the token subject and a time in minutes. For
example, to give an active user two hours time to verify a registration
use `token_expiry.registration = 120`
:param pyramid.request.Request request: the current request object
:param ordr2.models.account.TokenSubject subject: kind of token
:param ordr2.models.account.User owner: account the token is issued for
:param payload: extra data to store with the token, JSON serializable
:rtype: ordr2.models.account.Token
'''
settings_key = 'token_expiry.' + subject.name.lower()
minutes = request.registry.settings.get(settings_key, 5)
expires = datetime.utcnow() + timedelta(minutes=int(minutes))
return cls(
hash=uuid.uuid4().hex,
subject=subject,
payload=payload,
owner=owner,
expires=expires
)
@classmethod
def retrieve(cls, request, hash, subject=None):
''' returns a token from the database
The database is queried for a token with the given hash. If an
optional subject is given, the query will search only for tokens of
this kind.
The method will return None if a token could not be found or the
token has already expired. If the token has expired, it will be deleted
from the database
:param pyramid.request.Request request: the current request object
:param str hash: token hash
:param ordr2.models.account.TokenSubject subject: kind of token
:rtype: ordr2.models.account.Token or None
'''
query = request.dbsession.query(cls).filter_by(hash=hash)
if subject:
query = query.filter_by(subject=subject)
token = query.first()
if token is None:
return None
elif token.expires < datetime.utcnow():
request.dbsession.delete(token)
raise TokenExpired('Token has expired')
return token

39
ordr/models/meta.py

@ -0,0 +1,39 @@
''' SQL conventions and custom Encoders '''
import json
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import MetaData
from sqlalchemy.types import TypeDecorator, Unicode
# Recommended naming convention used by Alembic, as various different database
# providers will autogenerate vastly different names making migrations more
# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
metadata = MetaData(naming_convention=NAMING_CONVENTION)
Base = declarative_base(metadata=metadata)
class JsonEncoder(TypeDecorator):
''' Custom type for storing data structures as json-encoded string. '''
impl = Unicode
def process_bind_param(self, value, dialect):
''' inbound (to database) '''
if value is not None:
value = json.dumps(value)
return value
def process_result_value(self, value, dialect):
''' outbound (from database) '''
if value is not None:
value = json.loads(value)
return value

49
ordr/resources/__init__.py

@ -0,0 +1,49 @@
''' Resources (sub) package, used to connect URLs to views '''
from pyramid.security import Allow, Everyone, DENY_ALL
from .account import AccountResource
class RootResource:
''' The root resource for the application
:param pyramid.request.Request request: the current request object
'''
nav_active = 'welcome'
def __init__(self, request):
''' Create the root resource
:param pyramid.request.Request request: the current request object
'''
self.__name__ = None
self.__parent__ = None
self.request = request
def __acl__(self):
''' access controll list for the resource '''
return [(Allow, Everyone, 'view'), DENY_ALL]
def __getitem__(self, key):
''' retruns a child resource
:param str key: name of the child resource
:returns: child resource
:raises: KeyError if child resource is not found
'''
map = {
'account': AccountResource,
}
child_class = map[key]
return child_class(name=key, parent=self)
def includeme(config): # pragma: no cover
'''
Initialize the resources for traversal in a Pyramid app.
Activate this setup using ``config.include('ordr2.resources')``.
'''
config.set_root_factory(RootResource)

206
ordr/resources/account.py

@ -0,0 +1,206 @@
''' Resources (sub) package, used to connect URLs to views '''
import deform
from pyramid.security import Allow, Authenticated, Everyone, DENY_ALL
from ordr.models.account import Token, TokenSubject
from ordr.schemas.account import (
ChangePasswordSchema,
RegistrationSchema,
ResetPasswordSchema,
SettingsSchema
)
from .helpers import BaseChildResource
class RegistrationTokenResource(BaseChildResource):
''' Resource for vaildating a new registered user's email
:param pyramid.request.Request request: the current request object
: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, 'register'), DENY_ALL]
class RegistrationResource(BaseChildResource):
''' The resource for new user registration
:param pyramid.request.Request request: the current request object
: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, 'register'), 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 = {
'buttons': (
deform.Button(name='create', title='Create Account'),
deform.Button(
title='Cancel',
type='link',
value=self.request.resource_url(self.request.root),
css_class='btn btn-outline-secondary'
)
),
}
settings.update(kwargs)
return self._prepare_form(RegistrationSchema, **settings)
class PasswordResetTokenResource(BaseChildResource):
''' Resource for the reset password link
: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, Everyone, 'reset'), DENY_ALL]
def get_reset_form(self, **kwargs):
''' returns password reset form '''
settings = {
'buttons': (
deform.Button(name='change', title='Set New Password'),
deform.Button(name='cancel', title='Cancel'),
)
}
settings.update(kwargs)
return self._prepare_form(ResetPasswordSchema, **settings)
class PasswordResetResource(BaseChildResource):
''' The resource for resetting a forgotten password
: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, Everyone, 'reset'), 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, Everyone, 'view'),
(Allow, Everyone, 'login'),
(Allow, Everyone, 'logout'),
(Allow, Everyone, 'register'),
(Allow, Everyone, 'reset'),
(Allow, Authenticated, 'edit'),
DENY_ALL
]
def __getitem__(self, key):
''' returns a resource for child resource '''
# static child resources
map = {
'register': RegistrationResource,
'forgot': PasswordResetResource,
}
if key in map:
child_class = map[key]
return child_class(name=key, parent=self)
# change email verification
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)

27
ordr/resources/helpers.py

@ -0,0 +1,27 @@
''' Resources (sub) package, used to connect URLs to views '''
class BaseChildResource:
def __init__(self, name, parent, model=None):
''' Create a child resource
:param str name: the name of the resource
:param parent: the parent resouce
:param model: optional data model for the resource
'''
self.__name__ = name
self.__parent__ = parent
self.request = parent.request
self.model = model
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

53
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 .validators 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, action=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.
'''
schema = cls().bind(request=request)
if action is None:
action = request.resource_url(request.context, request.view_name)
settings = {'col_label': 3, 'col_input': 9, 'action': action}
settings.update(kwargs)
return deform.Form(schema, **settings)
def includeme(config): # pragma: no cover
'''
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'])

101
ordr/schemas/account.py

@ -0,0 +1,101 @@
import colander
import deform
from . import CSRFSchema
from .validators import (
deferred_unique_email_validator,
deferred_unique_username_validator,
deferred_password_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,
oid='registration_username'
)
first_name = colander.SchemaNode(
colander.String(),
oid='registration_first_name'
)
last_name = colander.SchemaNode(
colander.String(),
oid='registration_last_name'
)
email = colander.SchemaNode(
colander.String(),
validator=deferred_unique_email_validator
)
password = colander.SchemaNode(
colander.String(),
widget=deform.widget.CheckedPasswordWidget(),
validator=colander.Length(min=8)
)
class ResetPasswordSchema(CSRFSchema):
''' 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
)

64
ordr/schemas/validators.py

@ -0,0 +1,64 @@
''' 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 is not None:
if user != getattr(request.context, 'model', None):
# 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

1
ordr/scripts/__init__.py

@ -0,0 +1 @@
# package

60
ordr/scripts/initializedb.py

@ -0,0 +1,60 @@
import os
import sys
import transaction
from pyramid.paster import (
get_appsettings,
setup_logging,
)
from pyramid.scripts.common import parse_vars
from urllib.parse import urlparse
from ..models.meta import Base
from ..models import (
get_engine,
get_session_factory,
get_tm_session,
)
from ..models import Role, User
def usage(argv):
cmd = os.path.basename(argv[0])
print('usage: %s <config_uri> [var=value]\n'
'(example: "%s development.ini")' % (cmd, cmd))
sys.exit(1)
def main(argv=sys.argv):
if len(argv) < 2:
usage(argv)
config_uri = argv[1]
options = parse_vars(argv[2:])
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:
dbsession = get_tm_session(session_factory, transaction.manager)
account = User(
username='Holgi',
first_name='Holger',
last_name='Frey',
email='frey@imtek.de',
role=Role.ADMIN
)
account.set_password('test')
dbsession.add(account)

97
ordr/security.py

@ -0,0 +1,97 @@
''' User Authentication and Authorization '''
from passlib.context import CryptContext
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.security import Authenticated, Everyone
from pyramid.settings import aslist
from ordr.models.account import User
#: passlib context for hashing passwords
# at least one scheme must be set in advance, will be overridden by the
# settings in the .ini file.
password_context = CryptContext(schemes=['argon2'])
class AuthenticationPolicy(AuthTktAuthenticationPolicy):
''' How to authenticate users '''
def authenticated_userid(self, request):
''' returns the id of an authenticated user
heavy lifting done in get_user() attached to request
'''
user = request.user
if user is not None:
return user.id
def effective_principals(self, request):
''' returns a list of principals for the user '''
principals = [Everyone]
user = request.user
if user is not None:
principals.append(Authenticated)
principals.extend(user.principals)
return principals
def get_user(request):
''' retrieves the user object by the unauthenticated user id
:param pyramid.request.Request request:
the current request object
:rtype: :class:`ordr.models.account.User` or None
'''
user_id = request.unauthenticated_userid
if user_id is not None:
user = request.dbsession.query(User).filter_by(id=user_id).first()
if user and user.is_active:
return user
return None
def crypt_context_settings_to_string(settings, prefix='passlib.'):
''' returns a passlib context setting as a INI-formatted content
:param dict settings: settings for the crypt context
:param str prefix: prefix of the settings keys
:rtype: (str) config string in INI format for CryptContext.load()
This looks at first like a dump hack, but the parsing of all possible
context settings is quite a task. Since passlib has a context parser
included, this seems the most reliable way to do it.
'''
config_lines = ['[passlib]']
for ini_key, value in settings.items():
if ini_key.startswith(prefix):
context_key = ini_key.replace(prefix, '')
# the pyramid .ini format is different on lists
# than the .ini format used by passlib.
if context_key in {'schemes', 'deprecated'} and ',' not in value:
value = ','.join(aslist(value))
config_lines.append(f'{context_key} = {value}')
return '\n'.join(config_lines)
def includeme(config): # pragma: no cover
''' initializing authentication, authorization and password hash settings
Activate this setup using ``config.include('ordr.security')``.
'''
settings = config.get_settings()
# configure the passlib context manager for hashing user passwords
config_str = crypt_context_settings_to_string(settings, prefix='passlib.')
password_context.load(config_str)
# config for authentication and authorization
authn_policy = AuthenticationPolicy(
settings.get('auth.secret', ''),
hashalg='sha512',
)
config.set_authentication_policy(authn_policy)
config.set_authorization_policy(ACLAuthorizationPolicy())
# attach the get_user function returned by get_user_closure()
config.add_request_method(get_user, 'user', reify=True)

BIN
ordr/static/pyramid-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

21
ordr/static/scripts.js

@ -0,0 +1,21 @@
$(function() {
function capitalize(s){
return s.replace( /\b./g, function(a){ return a.toUpperCase(); } );
};
function generate_user_name() {
var first_name = $('#registration_first_name').val();
var last_name = $('#registration_last_name').val();
var user_name = capitalize(first_name) + capitalize(last_name);
return user_name.replace( /[\s-]/g, '')
};
// autocomplete of the username (registration form)
$('#registration_first_name').keyup(function () {
$('#registration_username').val( generate_user_name() );
});
$('#registration_last_name').keyup(function() {
$('#registration_username').val( generate_user_name() );
});
});

0
ordr/static/style.css

33
ordr/templates/account/forgotten_password_completed.jinja2

@ -0,0 +1,33 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Password Reset Succesfull</h3>
<p class="mt-3">Your password has been changed.</p>
<p>You can now <a href="{{ request.resource_url(request.root) }}">log in</a> again.</p>
</div>
</div>
{% endblock content %}

49
ordr/templates/account/forgotten_password_form.jinja2

@ -0,0 +1,49 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-primary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6 mt-3">
<p>Please enter your mail address or your username to reset your password.</p>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6">
<form action="{{request.resource_url(context)}}" method="POST">
<div class="form-group form-row mt-3">
<input type="hidden" name="csrf_token" value="{{get_csrf_token()}}">
<input type="text" class="form-control {% if formerror %}is-invalid{% endif %}" id="input-username" placeholder="Mail Address or Username" name="identifier" autofocus="autofocus">
{% if formerror %}
<div class="invalid-feedback">
Username or email address unknown, or account is not activated.
</div>
{% endif %}
</div>
<div class="form-group form-row mt-5">
<button type="submit" name="send_mail" class="btn btn-primary mr-1">Send Reset Link</button>
<button type="submit" name="cancel" class="btn btn-outline-secondary">Cancel</button>
</div>
</div>
</div>
{% endblock content %}

32
ordr/templates/account/forgotten_password_reset.jinja2

@ -0,0 +1,32 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Change your password</h3>
<p class="mt-3">{{ form.render()|safe }}</p>
</div>
</div>
{% endblock content %}

33
ordr/templates/account/forgotten_password_verify.jinja2

@ -0,0 +1,33 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Forgot Your Password?</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-primary">
Step 1: Validate Account
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Change Password
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Verify Your Email Address</h3>
<p class="mt-3">To continue the process, an email has been sent to you.</p>
<p>Please follow the link in the email to verify your account.</p>
</div>
</div>
{% endblock content %}

41
ordr/templates/account/login.jinja2

@ -0,0 +1,41 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row mt-5">
<div class="col-8 offset-2">
<div class="jumbotron">
<h1 class="display-4">Welcome to <span class="text-primary">ordr</span>!</h1>
<p class="lead">An order management system to simplify your shopping for laborartory supplies.</p>
</div>
</div>
</div>
<div class="row">
<div class="col-4 offset-2">
<h4 class="mb-4">Login</h4>
<form action="{{ request.resource_url(context, 'login') }}" method="POST">
<div class="form-group">
<input type="hidden" name="csrf_token" value="{{get_csrf_token()}}">
<input type="text" class="form-control {% if loginerror %}is-invalid{% endif %}" id="input-username" placeholder="Username" name="username" autofocus="autofocus">
</div>
<div class="form-group">
<input type="password" class="form-control {% if loginerror %}is-invalid{% endif %}" id="input-password" placeholder="Password" name="password">
{% if loginerror %}
<div class="invalid-feedback">
Username and password do not match, or account is not activated.
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Login</button>
<small class="float-right mt-2"><a href="/forgot">Forgot your password?</a></small>
</form>
</div>
<div class="col-4">
<h4 class="mb-4">Register</h4>
<p>
Registration is easy as 1-2-3.
Just fill out the <a href="/register">form</a> and as soon as your
account has been activated you can start shopping.
</p>
</div>
</div>
{% endblock content %}

16
ordr/templates/account/password_changed.jinja2

@ -0,0 +1,16 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Change Your Password</h1>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6">
<h3>Your password was changed successfully</h3>
<p class="mt-3">You can now log in with your new password.</p>
<p>Happy <a href="{{ request.resource_url(request.root) }}">ordering</a>
</div>
</div>
{% endblock content %}

14
ordr/templates/account/password_form.jinja2

@ -0,0 +1,14 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Change Your Password</h1>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6 mt-3">
{{ form.render()|safe }}
</div>
</div>
{% endblock content %}

36
ordr/templates/account/registration_completed.jinja2

@ -0,0 +1,36 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Registration {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Registration</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Registration
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Validate Email
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Registration Completed</h3>
<p class="mt-3">Thank you for verifying your email address.</p>
<p>Before you can start ordering, an administrator must activate your account</p>
<p>You'll receive an email when your account is activated</p>
</div>
</div>
{% endblock content %}

33
ordr/templates/account/registration_form.jinja2

@ -0,0 +1,33 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Registration {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Registration</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-primary">
Step 1: Registration
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 2: Validate Email
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
{{ form.render()|safe }}
</div>
</div>
{% endblock content %}

35
ordr/templates/account/registration_verify.jinja2

@ -0,0 +1,35 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Registration {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Registration</h1>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-2">
<p class="text-secondary">
Step 1: Registration
</p>
</div>
<div class="col-2">
<p class="text-primary">
Step 2: Validate Email
</p>
</div>
<div class="col-2">
<p class="text-secondary">
Step 3: Finished
</p>
</div>
</div>
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h3>Verify Your Email Address</h3>
<p class="mt-3">To complete the registration process an email has been sent to you.</p>
<p>Please follow the link in the email to verify your address and complete the registration process.</p>
</div>
</div>
{% endblock content %}

14
ordr/templates/account/settings_form.jinja2

@ -0,0 +1,14 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Change Settings</h1>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6 mt-3">
{{ form.render()|safe }}
</div>
</div>
{% endblock content %}

16
ordr/templates/account/settings_mail_changed.jinja2

@ -0,0 +1,16 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-6">
<h1>Change Settings</h1>
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-6">
<h3>Your email was changed successfully</h3>
<p class="mt-3">New notifications will be sent to {{request.user.email}}.</p>
<p>Happy <a href="{{ request.resource_url(request.root) }}">ordering</a>
</div>
</div>
{% endblock content %}

46
ordr/templates/deform/checked_password.pt

@ -0,0 +1,46 @@
<div i18n:domain="deform" tal:omit-tag=""
tal:define="oid oid|field.oid;
name name|field.name;
css_class css_class|field.widget.css_class;
style style|field.widget.style;
required required|'required' if field.required else None;
was_validated True if field.get_root().error else False;
is_invalid is_invalid|field.error and not field.widget.hidden and not field.typ.__class__.__name__=='Mapping';
is_valid was_validated and not is_invalid;
">
${field.start_mapping()}
<div>
<input type="password"
name="${name}"
value="${field.widget.redisplay and cstruct or ''}"
tal:attributes="class string: form-control ${css_class or ''} ${'is-invalid' if is_invalid else ''} ${'is-valid' if is_valid else ''};
style style;
required required;"
id="${oid}"
i18n:attributes="placeholder"
placeholder="Password"/>
</div>
<div class="mt-2">
<input type="password"
name="${name}-confirm"
value="${field.widget.redisplay and confirm or ''}"
tal:attributes="class string: form-control ${css_class or ''} ${'is-invalid' if is_invalid else ''} ${'is-valid' if is_valid else ''};
style style;
required required;"
id="${oid}-confirm"
i18n:attributes="placeholder"
placeholder="Confirm Password"/>
<!--! error message must directly follow input field for bootstrap 4 -->
<div class="invalid-feedback"
tal:define="errstr 'error-%s' % field.oid"
tal:repeat="msg field.error.messages()"
i18n:translate=""
tal:attributes="id repeat.msg.index==0 and errstr or
('%s-%s' % (errstr, repeat.msg.index))"
tal:condition="is_invalid">
${msg}
</div>
</div>
${field.end_mapping()}
</div>

110
ordr/templates/deform/form.pt

@ -0,0 +1,110 @@
<form
tal:define="style style|field.widget.style;
css_class css_class|string:${field.widget.css_class or field.css_class or ''};
item_template item_template|field.widget.item_template;
autocomplete autocomplete|field.autocomplete;
title title|field.title;
errormsg errormsg|field.errormsg;
description description|field.description;
buttons buttons|field.buttons;
use_ajax use_ajax|field.use_ajax;
ajax_options ajax_options|field.ajax_options;
formid formid|field.formid;
action action|field.action or None;
method method|field.method;
col_label col_label|field.col_label;
col_input col_input|field.col_input;
was_validated True if field.get_root().error else False;"
tal:attributes="autocomplete autocomplete;
style style;
class css_class;
action action;"
id="${formid}"
method="${method}"
enctype="multipart/form-data"
accept-charset="utf-8"
i18n:domain="deform"
>
<fieldset class="deform-form-fieldset">
<legend tal:condition="title">${title}}</legend>
<input type="hidden" name="_charset_" />
<input type="hidden" name="__formid__" value="${formid}"/>
<p class="section first" tal:condition="description">
${description}
</p>
<div tal:repeat="child field"
tal:replace="structure child.render_template(item_template)"/>
<div class="form-row deform-form-buttons">
<div class="col-${col_label}"></div>
<div class="form-group col-{$col_input} mt-4">
<tal:loop tal:repeat="button buttons">
<button
tal:define="btn_disposition repeat.button.start and 'btn-primary' or 'btn-default';"
tal:attributes="disabled button.disabled if button.disabled else None"
id="${formid+button.name}"
name="${button.name}"
type="${button.type}"
class="btn ${button.css_class or btn_disposition}"
value="${button.value}"
tal:condition="button.type != 'link'">
<span tal:condition="button.icon" class="glyphicon glyphicon-${button.icon}"></span>
${button.title}
</button>
<a
tal:define="btn_disposition repeat.button.start and 'btn-primary' or 'btn-default';
btn_href button.value|''"
class="btn ${button.css_class or btn_disposition}"
id="${field.formid + button.name}"
href="${btn_href}"
tal:condition="button.type == 'link'">
<span tal:condition="button.icon" class="glyphicon glyphicon-${button.icon}"></span>
${button.title}
</a>
</tal:loop>
</div>
</div>
</fieldset>
<script type="text/javascript" tal:condition="use_ajax">
$(function() {
// jquery handler for .ready() called
deform.addCallback(
'${formid}',
function(oid) {
var target = '#' + oid;
var options = {
target: target,
replaceTarget: true,
success: function() {
deform.processCallbacks();
deform.focusFirstInput(target);
},
beforeSerialize: function() {
// See http://bit.ly/1agBs9Z (hack to fix tinymce-related ajax bug)
if ('tinymce' in window) {
$(tinymce.get()).each(
function(i, el) {
var content = el.getContent();
var editor_input = document.getElementById(el.id);
editor_input.value = content;
});
}
}
};
var extra_options = ${ajax_options} || {};
$('#' + oid).ajaxForm($.extend(options, extra_options));
}
);
});
</script>
</form>

55
ordr/templates/deform/mapping_item.pt

@ -0,0 +1,55 @@
<div tal:define="error_class error_class|field.widget.error_class;
description description|field.description;
title title|field.title;
oid oid|field.oid;
hidden hidden|field.widget.hidden;
category category|field.widget.category;
structural hidden or category == 'structural';
required required|'required' if field.required else None;
was_validated True if field.get_root().error else False;
is_invalid is_invalid|field.error and not field.widget.hidden and not field.typ.__class__.__name__=='Mapping';
col_label col_label|field.col_label;
col_input col_input|field.col_input;"
class="form-group form-row ${field.error and 'has-error' or ''} ${field.widget.item_css_class or ''} ${field.default_item_css_class()}"
title="${description}"
id="item-${oid}"
tal:omit-tag="structural"
i18n:domain="deform">
<label for="${oid}"
class="control-label col-${col_label} col-form-label ${required and 'required' or ''}"
tal:condition="not structural"
id="req-${oid}"
>
${title}
</label>
<div class="col-${col_input}">
<div tal:define="input_prepend field.widget.input_prepend | None;
input_append field.widget.input_append | None"
tal:omit-tag="not (input_prepend or input_append)"
class="input-group">
<div class="input-group-prepend" tal:condition="input_prepend">
<div class="input-group-text">${input_prepend}</div>
</div>
<span tal:replace="structure field.serialize(cstruct).strip()"></span>
<div class="input-group-append" tal:condition="input_append">
<div class="input-group-text">${input_append}</div>
</div>
</div>
<div class="invalid-feedback"
tal:define="errstr 'error-%s' % field.oid"
tal:repeat="msg field.error.messages()"
i18n:translate=""
tal:attributes="id repeat.msg.index==0 and errstr or
('%s-%s' % (errstr, repeat.msg.index))"
tal:condition="is_invalid">
${msg}
</div>
<small tal:condition="field.description and not field.widget.hidden"
class="form-text text-muted" >
${field.description}
</small>
</div>
</div>

18
ordr/templates/deform/password.pt

@ -0,0 +1,18 @@
<span tal:define="name name|field.name;
css_class css_class|field.widget.css_class;
oid oid|field.oid;
required required|'required' if field.required else None;
mask mask|field.widget.mask;
mask_placeholder mask_placeholder|field.widget.mask_placeholder;
style style|field.widget.style;
was_validated True if field.get_root().error else False;
is_invalid is_invalid|field.error and not field.widget.hidden and not field.typ.__class__.__name__=='Mapping';
is_valid was_validated and not is_invalid;
"
tal:omit-tag="">
<input type="password" name="${name}" value="${cstruct}"
tal:attributes="class string: form-control ${css_class or ''} ${'is-invalid' if is_invalid else ''} ${'is-valid' if is_valid else ''};
style style;
required required"
id="${oid}"/>
</span>

17
ordr/templates/deform/readonly/textinput.pt

@ -0,0 +1,17 @@
<span tal:define="name name|field.name;
css_class css_class|field.widget.css_class;
oid oid|field.oid;
mask mask|field.widget.mask;
mask_placeholder mask_placeholder|field.widget.mask_placeholder;
style style|field.widget.style;
was_validated True if field.get_root().error else False;
is_invalid is_invalid|field.error and not field.widget.hidden and not field.typ.__class__.__name__=='Mapping';
is_valid was_validated and not is_invalid;
"
tal:omit-tag="">
<input type="text" name="${name}" value="${cstruct}"
tal:attributes="class string: form-control ${css_class or ''} ${'is-invalid' if is_invalid else ''} ${'is-valid' if is_valid else ''};
style style"
id="${oid}"
readonly="readonly"/>
</span>

18
ordr/templates/deform/textinput.pt

@ -0,0 +1,18 @@
<span tal:define="name name|field.name;
css_class css_class|field.widget.css_class;
oid oid|field.oid;
required required|'required' if field.required else None;
mask mask|field.widget.mask;
mask_placeholder mask_placeholder|field.widget.mask_placeholder;
style style|field.widget.style;
was_validated True if field.get_root().error else False;
is_invalid is_invalid|field.error and not field.widget.hidden and not field.typ.__class__.__name__=='Mapping';
is_valid was_validated and not is_invalid;
"
tal:omit-tag="">
<input type="text" name="${name}" value="${cstruct}"
tal:attributes="class string: form-control ${css_class or ''} ${'is-invalid' if is_invalid else ''} ${'is-valid' if is_valid else ''};
style style;
required required"
id="${oid}"/>
</span>

25
ordr/templates/emails/email_change.jinja2

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>[ordr] verify your new 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 new 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>

25
ordr/templates/emails/password_reset.jinja2

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>[ordr] reset your password</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>
To set a new password for the account "{{ user.username }}" follow 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>

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>

14
ordr/templates/errors/404_file_not_found.jinja2

@ -0,0 +1,14 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Error {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-8">
<h1 class="mt-3">An Error has occured</h1>
<p class="mt-4">The page you are looking for could not be found</p>
<small class="text-secondary">404 - Page not found</small>
</div>
</div>
{% endblock content %}

14
ordr/templates/errors/410_token_expiry.jinja2

@ -0,0 +1,14 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block title %} Ordr | Error {% endblock title %}
{% block content %}
<div class="row justify-content-md-center mt-3">
<div class="col-8">
<h1 class="mt-3">An Error has occured</h1>
<p class="mt-4">The link you've clicked has expired.</p>
<small class="text-secondary">410 - Gone</small>
</div>
</div>
{% endblock content %}

82
ordr/templates/layout.jinja2

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="{{request.locale_name}}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<meta name="description" content="ordr">
<meta name="author" content="IMTEk / CPI / Holger Frey">
<link rel="shortcut icon" href="{{request.static_url('ordr:static/pyramid-16x16.png')}}">
<title>{% block title %} Ordr {% endblock title %}</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
<!-- Deform form renderin gcss -->
{# <link rel="stylesheet" href="{{request.static_url('deform:static/css/form.css')}}" type="text/css" media="screen" /> #}
<!-- Custom styles for this scaffold -->
<link href="{{request.static_url('ordr:static/style.css')}}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-dark bg-dark navbar-expand-sm">
<a class="navbar-brand text-primary" href="{{ request.resource_url(request.root) }}"><strong>ordr</strong></a>
{% if not request.user %}
<ul class="navbar-nav mr-auto">
<li class="nav-item {% if context.nav_active=='welcome' and request.view_name=='login' %}active{% endif %}">
<a href="{{ request.resource_url(request.root) }}" class="nav-link">Welcome</a>
</li>
<li class="nav-item {% if context.nav_active=='welcome' and request.view_name=='faq' %}active{% endif %}">
<a href="{{ request.resource_url(request.root, 'faq') }}" class="nav-link">FAQs</a>
</li>
<li class="nav-item {% if context.nav_active=='registration' %}active{% endif %}">
<a href="{{ request.resource_url(request.root, 'account', 'register') }}" class="nav-link">Register</a>
</li>
</ul>
{% else %}
<ul class="navbar-nav mr-auto">
<li class="nav-item {% if context.nav_active=='orders' %}active{% endif %}">
<a href="{{ request.resource_url(request.root, 'orders') }}" class="nav-link">Orders</a>
</li>
<li class="nav-item {% if context.nav_active=='welcome' and request.view_name=='faq' %}active{% endif %}">
<a href="{{ request.resource_url(request.root, 'faq') }}" class="nav-link">FAQs</a>
</li>
{% if 'role:admin' in request.user.principals %}
<li class="nav-item {% if context.nav_active=='admin' %}active{% endif %}">
<a href="{{ request.resource_url(request.root, 'admin') }}" class="nav-link">Admin</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" >
{{ request.user }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="userDropdown">
<a class="dropdown-item" href="{{ request.resource_url(request.root, 'account', 'logout') }}">Logout</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item small" href="{{ request.resource_url(request.root, 'account', 'settings') }}">Settings</a>
<a class="dropdown-item small" href="{{ request.resource_url(request.root, 'account', 'password') }}">Change Password</a>
</div>
</li>
</ul>
{% endif %}
</nav>
<div class="container-fluid content">
{% block content %}
<p>No content</p>
{% endblock content %}
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<script src="{{request.static_url('ordr:static/scripts.js')}}"></script>
</body>
</html>

8
ordr/templates/pages/faq.jinja2

@ -0,0 +1,8 @@
{% extends "ordr:templates/layout.jinja2" %}
{% block content %}
<div class="content">
<h1>FAQ</h1>
<p class="lead">Welcome to <span class="font-normal">Ordr</span>, a&nbsp;Pyramid application generated&nbsp;by<br><span class="font-normal">Cookiecutter</span>.</p>
</div>
{% endblock content %}

14
ordr/views/__init__.py

@ -0,0 +1,14 @@
''' views (sub) package for ordr '''
def includeme(config): # pragma: no cover
'''
Initialize the views in a Pyramid app.
Activate this setup using ``config.include('ordr2.views')``.
'''
settings = config.get_settings()
age = int(settings.get('static_views.cache_max_age', 3600))
config.add_static_view('static', 'ordr:static', cache_max_age=age)
config.add_static_view('deform', 'deform:static', cache_max_age=age)

402
ordr/views/account.py

@ -0,0 +1,402 @@
''' views for user accounts
This includes login, logout, registration, forgotten passwords, changing
settings and passwords
'''
import deform
from pyramid.httpexceptions import HTTPFound
from pyramid.security import remember, forget
from pyramid.view import view_config
from sqlalchemy import func, or_
from ordr.events import (
ChangeEmailNotification,
PasswordResetNotification,
RegistrationNotification
)
from ordr.models.account import Role, TokenSubject, User
# account resource root
@view_config(
context='ordr.resources.account.AccountResource',
permission='view'
)
def account(context, request):
''' redirect if '/account' was requested directly '''
return HTTPFound(request.resource_url(request.root))
# login and logout
@view_config(
context='ordr.resources.account.AccountResource',
name='login',
request_method='GET',
permission='login',
renderer='ordr:templates/account/login.jinja2',
)
def login(context, request):
''' shows the login page '''
context.nav_active = 'welcome'
return {'loginerror': False}
@view_config(
context='ordr.resources.account.AccountResource',
name='login',
request_method='POST',
permission='login',
renderer='ordr:templates/account/login.jinja2',
)
def check_login(context, request):
''' check user credentials '''
username = request.POST.get('username')
password = request.POST.get('password')
user = (
request.dbsession
.query(User)
.filter_by(username=username)
.first()
)
if user and user.is_active and user.check_password(password):
headers = remember(request, user.id)
return HTTPFound(request.resource_url(request.root), headers=headers)
context.nav_active = 'welcome'
return {'loginerror': True}
@view_config(
context='ordr.resources.account.AccountResource',
name='logout',
permission='logout'
)
def logout(context, request):
''' log out of an user '''
headers = forget(request)
return HTTPFound(request.resource_url(request.root), headers=headers)
# registration process
@view_config(
context='ordr.resources.account.RegistrationResource',
permission='register',
request_method='GET',
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='register',
request_method='POST',
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()
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'))
@view_config(
context='ordr.resources.account.RegistrationResource',
name='verify',
permission='register',
request_method='GET',
renderer='ordr:templates/account/registration_verify.jinja2'
)
def registration_verify_email(context, request):
''' show email verification text '''
return {}
@view_config(
context='ordr.resources.account.RegistrationTokenResource',
permission='register',
request_method='GET',
renderer='ordr:templates/account/registration_completed.jinja2'
)
def registration_completed(context, request):
''' registration is completed, awaiting activation by admin '''
token = context.model
account = token.owner
account.role = Role.NEW
request.dbsession.delete(token)
return {}
# forgotten password process
@view_config(
context='ordr.resources.account.PasswordResetResource',
permission='reset',
request_method='GET',
renderer='ordr:templates/account/forgotten_password_form.jinja2'
)
def forgotten_password_form(context, request):
''' show forgotten password form '''
return {'formerror': False}
@view_config(
context='ordr.resources.account.PasswordResetResource',
permission='reset',
request_method='POST',
renderer='ordr:templates/account/forgotten_password_form.jinja2'
)
def forgotten_password_form_processing(context, request):
''' process forgotten password form '''
if 'cancel' in request.POST:
return HTTPFound(request.resource_url(request.root))
identifier = request.POST.get('identifier', '')
account = (
request.dbsession
.query(User)
.filter(or_(
func.lower(User.username) == identifier.lower(),
func.lower(User.email) == identifier.lower()
))
.first()
)
if account is None or not account.is_active:
return {'formerror': True}
# create a verify-new-account token and send email
token = account.issue_token(request, TokenSubject.RESET_PASSWORD)
notification = PasswordResetNotification(
request,
account,
{'token': token}
)
request.registry.notify(notification)
return HTTPFound(request.resource_url(context, 'verify'))
@view_config(
context='ordr.resources.account.PasswordResetResource',
name='verify',
permission='reset',
request_method='GET',
renderer='ordr:templates/account/forgotten_password_verify.jinja2'
)
def forgotten_password_verify_email(context, request):
''' show email verification text '''
return {}
@view_config(
context='ordr.resources.account.PasswordResetResource',
name='completed',
permission='reset',
request_method='GET',
renderer='ordr:templates/account/forgotten_password_completed.jinja2'
)
def forgotten_password_completed(context, request):
''' user is verified, process reset password form '''
return {}
@view_config(
context='ordr.resources.account.PasswordResetTokenResource',
permission='reset',
request_method='GET',
renderer='ordr:templates/account/forgotten_password_reset.jinja2'
)
def reset_password_form(context, request):
''' user is verified, show reset password form '''
form = context.get_reset_form()
return {'form': form}
@view_config(
context='ordr.resources.account.PasswordResetTokenResource',
permission='reset',
request_method='POST',
renderer='ordr:templates/account/forgotten_password_reset.jinja2'
)
def reset_password_form_processing(context, request):
''' process the password reset form '''
if 'change' not in request.POST:
return HTTPFound(request.resource_url(request.root))
form = context.get_reset_form()
data = request.POST.items()
try:
appstruct = form.validate(data)
except deform.ValidationFailure as e:
return {'form': form}
# set new password
token = context.model
account = token.owner
account.set_password(appstruct['password'])
request.dbsession.delete(token)
return HTTPFound(request.resource_url(context.__parent__, 'completed'))
# account settings
@view_config(
context='ordr.resources.account.AccountResource',
permission='edit',
name='settings',
request_method='GET',
renderer='ordr:templates/account/settings_form.jinja2'
)
def settings_form(context, request):
''' show the settings form '''
prefill = {
'username': request.user.username,
'first_name': request.user.first_name,
'last_name': request.user.last_name,
'email': request.user.email,
}
form = context.get_settings_form(prefill=prefill)
return {'form': form}
@view_config(
context='ordr.resources.account.AccountResource',
permission='edit',
name='settings',
request_method='POST',
renderer='ordr:templates/account/settings_form.jinja2'
)
def settings_form_processing(context, request):
''' process the settings form '''
if 'change' not in request.POST:
return HTTPFound(request.resource_url(request.root))
form = context.get_settings_form()
data = request.POST.items()
try:
appstruct = form.validate(data)
except deform.ValidationFailure as e:
return {'form': form}
# form validation successfull, change user
request.user.first_name = appstruct['first_name']
request.user.last_name = appstruct['last_name']
if appstruct['email'] == request.user.email:
# email was not changed
return HTTPFound(request.resource_url(request.root))
# create a verify-new-email token and send email
token = request.user.issue_token(
request,
TokenSubject.CHANGE_EMAIL,
payload={'email': appstruct['email']}
)
notification = ChangeEmailNotification(
request,
account,
{'token': token},
send_to=appstruct['email']
)
request.registry.notify(notification)
return HTTPFound(request.resource_url(context, 'verify'))
@view_config(
context='ordr.resources.account.ChangeEmailTokenResource',
permission='edit',
request_method='GET',
renderer='ordr:templates/account/settings_mail_changed.jinja2'
)
def verify_email_change(context, request):
''' show email verification text '''
payload = context.model.payload
request.user.email = payload['email']
request.dbsession.delete(context.model)
return {}
# change password
@view_config(
context='ordr.resources.account.AccountResource',
permission='edit',
name='password',
request_method='GET',
renderer='ordr:templates/account/password_form.jinja2'
)
def password_form(context, request):
''' show the change password form '''
form = context.get_password_form()
return {'form': form}
@view_config(
context='ordr.resources.account.AccountResource',
permission='edit',
name='password',
request_method='POST',
renderer='ordr:templates/account/password_form.jinja2'
)
def password_form_processing(context, request):
''' process the change password form '''
if 'change' not in request.POST:
return HTTPFound(request.resource_url(request.root))
form = context.get_password_form()
data = request.POST.items()
try:
appstruct = form.validate(data)
except deform.ValidationFailure as e:
return {'form': form}
# form validation successfull, change the password
request.user.set_password(appstruct['password'])
return HTTPFound(request.resource_url(context, 'changed'))
@view_config(
context='ordr.resources.account.AccountResource',
permission='edit',
name='changed',
request_method='GET',
renderer='ordr:templates/account/password_changed.jinja2'
)
def password_changed(context, request):
''' the password changed message '''
return {}

22
ordr/views/errors.py

@ -0,0 +1,22 @@
from pyramid.view import notfound_view_config, view_config
from ordr.models.account import TokenExpired
@notfound_view_config(
renderer='ordr:templates/errors/404_file_not_found.jinja2'
)
def notfound_view(context, request):
''' display a file not found page '''
request.response.status = 404
return {}
@view_config(
context=TokenExpired,
renderer='ordr:templates/errors/410_token_expiry.jinja2'
)
def token_expired(context, request):
''' display page describing expired token '''
request.response.status = 410
return {}

26
ordr/views/pages.py

@ -0,0 +1,26 @@
from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config
@view_config(
context='ordr.resources.RootResource',
permission='view',
)
def welcome(context, request):
''' web root redirects '''
if request.user:
redirect_to = request.resource_url(context, 'orders')
else:
redirect_to = request.resource_url(context, 'account', 'login')
return HTTPFound(redirect_to)
@view_config(
context='ordr.resources.RootResource',
name='faq',
permission='view',
renderer='ordr:templates/pages/faq.jinja2'
)
def faq(context, request):
''' displays the FAQ page '''
return {}

65
production.ini

@ -0,0 +1,65 @@
###
# app configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
###
[app:main]
use = egg:ordr
pyramid.reload_templates = false
pyramid.debug_authorization = false
pyramid.debug_notfound = false
pyramid.debug_routematch = false
pyramid.default_locale_name = en
sqlalchemy.url = sqlite:///%(here)s/ordr.sqlite
retry.attempts = 3
###
# wsgi server configuration
###
[server:main]
use = egg:waitress#main
listen = *:6543
###
# logging configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
###
[loggers]
keys = root, ordr, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_ordr]
level = WARN
handlers =
qualname = ordr
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

8
requirements_dev.txt

@ -0,0 +1,8 @@
pip>=9.0.1
bumpversion>=0.5.3
wheel>=0.30.0
flake8>=3.5.0
coverage>=4.5.1
pytest>=3.4.1
pytest-runner>=2.11.1

30
setup.cfg

@ -0,0 +1,30 @@
[bumpversion]
current_version = 0.0.1
commit = True
tag = True
[bumpversion:file:setup.py]
search = version='{current_version}'
replace = version='{new_version}'
[bumpversion:file:ordr/__init__.py]
search = __version__ = '{current_version}'
replace = __version__ = '{new_version}'
[bdist_wheel]
universal = 1
[flake8]
exclude = docs
ignore = W293
hang_closing = True
[aliases]
test = pytest
[tool:pytest]
testpaths = tests
python_files = *.py
collect_ignore = ['setup.py']
xfail_strict = true

66
setup.py

@ -0,0 +1,66 @@
import os
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, 'README.txt')) as f:
README = f.read()
with open(os.path.join(here, 'CHANGES.txt')) as f:
CHANGES = f.read()
requires = [
'argon2_cffi',
'bcrypt',
'deform',
'passlib',
'plaster_pastedeploy',
'pyramid >= 1.9a',
'pyramid_debugtoolbar',
'pyramid_jinja2',
'pyramid_listing',
'pyramid_mailer',
'pyramid_retry',
'pyramid_tm',
'SQLAlchemy',
'transaction',
'zope.sqlalchemy',
'waitress',
]
tests_require = [
'WebTest >= 1.3.1', # py3 compat
'pytest',
'pytest-cov',
]
setup(
name='ordr',
version='0.0.1',
description='Ordr',
long_description=README + '\n\n' + CHANGES,
classifiers=[
'Programming Language :: Python',
'Framework :: Pyramid',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
],
author='',
author_email='',
url='',
keywords='web pyramid pylons',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
extras_require={
'testing': tests_require,
},
install_requires=requires,
entry_points={
'paste.app_factory': [
'main = ordr:main',
],
'console_scripts': [
'initialize_ordr_db = ordr.scripts.initializedb:main',
],
},
)

89
tests/__init__.py

@ -0,0 +1,89 @@
import pytest
import transaction
from pyramid import testing
from pyramid.csrf import get_csrf_token
APP_SETTINGS = {
'sqlalchemy.url': 'sqlite:///:memory:',
'session.secret': 'something',
'session.auto_csrf': True,
'passlib.schemes': 'argon2 bcrypt',
'passlib.default': 'argon2',
'passlib.deprecated': 'auto',
'mail.default_sender': 'ordr@example.com'
}
EXAMPLE_USER_DATA = {
'UNVALIDATED': (1, 'Graham', 'Chapman'),
'NEW': (2, 'John', 'Cleese'),
'USER': (3, 'Terry', 'Gilliam'),
'PURCHASER': (4, 'Eric', 'Idle'),
'ADMIN': (5, 'Terry', 'Jones'),
'INACTIVE': (6, 'Michael', 'Palin'),
}
# fixtures
@pytest.fixture(scope='session')
def app_config():
''' fixture for tests requiring a pyramid.testing setup '''
with testing.testConfig(settings=APP_SETTINGS) as config:
config.include('pyramid_jinja2')
config.include('pyramid_listing')
config.include('pyramid_mailer.testing')
yield config
@pytest.fixture(scope='function')
def dbsession(app_config):
''' fixture for testing with database connection '''
from ordr.models.meta import Base
from ordr.models import (
get_engine,
get_session_factory,
get_tm_session
)
settings = app_config.get_settings()
engine = get_engine(settings)
session_factory = get_session_factory(engine)
session = get_tm_session(session_factory, transaction.manager)
Base.metadata.create_all(engine)
yield session
transaction.abort()
Base.metadata.drop_all(engine)
# helpers
def get_example_user(role):
''' get the user model for one well known user '''
from ordr.models import User
id_, first_name, last_name = EXAMPLE_USER_DATA[role.name]
user = User(
id=id_,
username=first_name + last_name,
first_name=first_name,
last_name=last_name,
email=last_name.lower() + '@example.com',
role=role
)
user.set_password(first_name)
return user
def get_post_request(data, **kwargs):
''' returns a dummy request with csrf_token for validating deform forms '''
request = testing.DummyRequest()
post_data = {'csrf_token': get_csrf_token(request)}
post_data.update(data)
return testing.DummyRequest(POST=post_data, **kwargs)

92
tests/_functional/__init__.py

@ -0,0 +1,92 @@
''' functional tests for ordr2 '''
import pytest
import re
import transaction
import webtest
from bs4 import BeautifulSoup
from .. import APP_SETTINGS, get_example_user
WEBTEST_SETTINGS = APP_SETTINGS.copy()
WEBTEST_SETTINGS['pyramid.includes'] = [
'pyramid_mailer.testing'
]
class CustomTestApp(webtest.TestApp):
''' might add custom functionality to webtest.TestApp '''
pass
def login(self, username, password):
''' login '''
self.logout()
result = self.get('/account/login')
login_form = result.forms[0]
login_form['username'] = username
login_form['password'] = password
login_form.submit()
response = self.get('/faq')
return username in response
def logout(self):
''' logout '''
self.get('/account/logout')
def reset(self):
''' reset the webapp '''
self.logout()
super().reset()
def create_users(dbsession):
''' create example users '''
from ordr.models.account import Role
for role in Role:
user = get_example_user(role)
dbsession.add(user)
def get_token_url(email, prefix='/'):
''' extracts an account token url from an email '''
soup = BeautifulSoup(email.html, 'html.parser')
for link in soup.find_all('a'):
if re.search(prefix + '[a-f0-9]{32}', link['href']):
return link['href']
@pytest.fixture(scope='module')
def testappsetup():
''' setup of fixture for using webtest
this fixture just sets up the testapp. please use the testapp() fixture
below for real tests.
'''
from ordr.models.meta import Base
from ordr.models import get_tm_session
from ordr import main
app = main({}, **WEBTEST_SETTINGS)
testapp = CustomTestApp(app)
session_factory = app.registry['dbsession_factory']
engine = session_factory.kw['bind']
Base.metadata.create_all(engine)
with transaction.manager:
# set up test data here
dbsession = get_tm_session(session_factory, transaction.manager)
create_users(dbsession)
yield testapp
Base.metadata.drop_all(engine)
@pytest.fixture(scope='function')
def testapp(testappsetup):
''' fixture using webtests, resets the logged every time '''
testappsetup.reset()
yield testappsetup

10
tests/_functional/account/__init__.py

@ -0,0 +1,10 @@
''' functional tests for ordr accounts '''
from .. import testappsetup, testapp, get_token_url # noqa: F401
def test_account_root(testapp): # noqa: F811
''' check the redirect if '/account' is requested '''
testapp.login('TerryGilliam', 'Terry')
response = testapp.get('/account')
assert response.location == 'http://localhost/'

89
tests/_functional/account/forgotten_password.py

@ -0,0 +1,89 @@
''' functional tests for ordr2.views.forgotten_password '''
from pyramid_mailer import get_mailer
from . import testappsetup, testapp, get_token_url # noqa: F401
def test_forgot_password_process(testapp): # noqa: F811
''' test the forgot password form '''
response = testapp.get('/account/forgot')
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
assert active_nav is None
assert 'Step 1: Validate Account' in active_step.text
assert 'Forgot Your Password?' in response
assert 'unknown username or email' not in response
# fill out this form with invalid data
form = response.form
form['identifier'] = 'unknown identifier'
response = form.submit(name='send_mail')
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
assert active_nav is None
assert 'Step 1: Validate Account' in active_step.text
assert 'Forgot Your Password?' in response
assert 'Username or email address unknown' in response
# fill out this form with valid data
response = testapp.get('/account/forgot')
form = response.form
form['identifier'] = 'TerryGilliam'
response = form.submit(name='send_mail')
assert response.location == 'http://localhost/account/forgot/verify'
response = response.follow()
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
assert active_nav is None
assert 'Step 1: Validate Account' in active_step.text
assert 'Verify Your Email Address' in response
# click the email verification token
mailer = get_mailer(testapp.app.registry)
email = mailer.outbox[-1]
assert email.subject == '[ordr] Password Reset'
token_link = get_token_url(email, prefix='/forgot/')
response = testapp.get(token_link)
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
assert active_nav is None
assert 'Step 2: Change Password' in active_step.text
assert 'Forgot Your Password?' in response
assert 'do not match' not in response
# fill out the change password form with invalid data
form = response.form
form['password'] = 'some passwords'
form['password-confirm'] = 'that do not match'
response = form.submit(name='change')
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
assert active_nav is None
assert 'Step 2: Change Password' in active_step.text
assert 'Forgot Your Password?' in response
assert 'Password did not match confirm' in response
# fill out the change password form with valid data
form = response.form
form['password'] = 'Lost in La Mancha'
form['password-confirm'] = 'Lost in La Mancha'
response = form.submit(name='change')
assert response.location == 'http://localhost/account/forgot/completed'
response = response.follow()
active_nav = response.html.find('li', class_='active')
active_step = response.html.find('p', class_='text-primary')
content = response.html.find('div', class_='content')
assert active_nav is None
assert 'Step 3: Finished' in active_step.text
assert 'Forgot Your Password?' in response
assert 'Password Reset Succesfull' in response
assert content.a['href'] == 'http://localhost/'
assert content.a.text == 'log in'
# old password should not work but the new one
assert not testapp.login('TerryGilliam', 'Terry')
assert testapp.login('TerryGilliam', 'Lost in La Mancha')

65
tests/_functional/account/login_logout.py

@ -0,0 +1,65 @@
''' functional tests for ordr2.views.pages '''
import pytest
from . import testappsetup, testapp # noqa: F401
def test_login_get(testapp): # noqa: F811
''' test the login form '''
response = testapp.get('/account/login')
active = response.html.find('li', class_='active')
form = response.form
assert active.a['href'] == 'http://localhost/'
assert form.action == 'http://localhost/account/login'
def test_login_ok(testapp): # noqa: F811
''' test login form with valid credentials '''
response = testapp.get('/account/login')
login_form = response.forms[0]
login_form['username'] = 'TerryGilliam'
login_form['password'] = 'Terry'
response = login_form.submit()
assert response.location == 'http://localhost/'
response = testapp.get('/faq')
assert 'TerryGilliam' in response
@pytest.mark.parametrize( # noqa: F811
'username,password',
[('John', 'Cleese'), ('unknown user', 'wrong password')]
)
def test_login_denied(testapp, username, password):
''' test login form with invalid credentials '''
response = testapp.get('/account/login')
login_form = response.forms[0]
login_form['username'] = 'John'
login_form['password'] = 'Cleese'
response = login_form.submit()
assert 'account is not activated' in response
def test_logout(testapp): # noqa: F811
''' test login form with valid credentials '''
response = testapp.get('/account/login')
login_form = response.forms[0]
login_form['username'] = 'TerryGilliam'
login_form['password'] = 'Terry'
login_form.submit()
response = testapp.get('/faq')
assert 'TerryGilliam' in response
response = testapp.get('/account/logout')
assert response.location == 'http://localhost/'
response = testapp.get('/faq')
assert 'TerryGilliam' not in response

58
tests/_functional/account/registration.py

@ -0,0 +1,58 @@
''' functional tests for ordr2.views.registration '''
from pyramid_mailer import get_mailer
from . import testappsetup, testapp, get_token_url # noqa: F401
def test_registration_form(testapp): # noqa: F811
''' test the registration form '''
response = testapp.get('/account/register')
active = response.html.find('li', class_='active')
assert active.a['href'] == 'http://localhost/account/register'
assert 'Registration' in response.html.title.text
def test_registration_form_invalid(testapp): # noqa: F811
''' test the registration form with invalid data '''
response = testapp.get('/account/register')
form = response.form
form['email'] = 'not an email address'
response = form.submit(name='create')
assert 'Invalid email address' in response
assert 'Registration' in response.html.title.text
def test_registration_process(testapp): # noqa: F811
''' test the registration process with valid data '''
response = testapp.get('/account/register')
form = response.form
form['username'] = 'AmyMcDonald',
form['first_name'] = 'Amy',
form['last_name'] = 'McDonald',
form['email'] = 'amy.mcdonald@example.com',
form['password'] = 'Make Amy McDonald A Rich Girl Fund',
form['password-confirm'] = 'Make Amy McDonald A Rich Girl Fund',
response = form.submit(name='create')
assert response.location == 'http://localhost/account/register/verify'
response = response.follow()
active = response.html.find('li', class_='active')
assert active.a['href'] == 'http://localhost/account/register'
assert 'Please follow the link in the email' in response
assert 'Registration' in response.html.title.text
# click the email verification token
mailer = get_mailer(testapp.app.registry)
email = mailer.outbox[-1]
assert email.subject == '[ordr] Please verify your email address'
token_link = get_token_url(email, prefix='/account/register/')
response = testapp.get(token_link)
active = response.html.find('li', class_='active')
assert active.a['href'] == 'http://localhost/account/register'
assert 'Registration Completed' in response
assert 'Registration' in response.html.title.text

125
tests/_functional/account/settings.py

@ -0,0 +1,125 @@
''' functional tests for ordr2.views.account.py '''
from pyramid_mailer import get_mailer
from .. import testappsetup, testapp, get_token_url # noqa: F401
def test_account_change_settings(testapp): # noqa: F811
testapp.login('TerryGilliam', 'Terry')
response = testapp.get('/account/settings')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Settings' in response
assert 'value="gilliam@example.com"' in response
assert 'Wrong Password' not in response
# fill out the form without confirmation password
form = response.form
form['first_name'] = 'Amy'
form['last_name'] = 'McDonald'
response = form.submit(name='change')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Settings' in response
assert 'required' in response
# fill out the form with invalid data but correct password
response = testapp.get('/account/settings')
form = response.form
form['first_name'] = 'Amy'
form['last_name'] = 'McDonald'
form['email'] = 'this is not an email address'
form['confirmation'] = 'Terry'
response = form.submit(name='change')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Settings' in response
assert 'Invalid email address' in response
# fill out the form with valid data and correct password
response = testapp.get('/account/settings')
form = response.form
form['first_name'] = 'Amy'
form['last_name'] = 'McDonald'
form['confirmation'] = 'Terry'
response = form.submit(name='change')
assert response.location == 'http://localhost/'
response = testapp.get('/account/settings')
assert 'value="Amy"' in response
def test_account_change_email(testapp): # noqa: F811
testapp.login('TerryGilliam', 'Terry')
response = testapp.get('/account/settings')
# fill out the form with valid data and correct password
form = response.form
form['email'] = 'amy@example.com'
form['confirmation'] = 'Terry'
response = form.submit(name='change')
assert response.location == 'http://localhost/account/verify'
# click the email verification token
mailer = get_mailer(testapp.app.registry)
email = mailer.outbox[-1]
assert email.subject == '[ordr] Verify New Email Address'
assert email.recipients == ['amy@example.com']
token_link = get_token_url(email, prefix='/account/')
response = testapp.get(token_link)
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Settings' in response
assert 'changed sucessfully' not in response
def test_account_change_password(testapp): # noqa: F811
testapp.login('TerryGilliam', 'Terry')
response = testapp.get('/account/password')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Password' in response
assert 'Wrong Password' not in response
# fill out the form with incorrect confirmation password
form = response.form
form['password'] = 'Lost in La Mancha'
form['password-confirm'] = 'Lost in La Mancha'
form['confirmation'] = 'Unknown Password'
response = form.submit(name='change')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Password' in response
assert 'Wrong password' in response
# fill out the form with invalid data but correct password
response = testapp.get('/account/password')
form = response.form
form['password'] = 'Lost in La Mancha'
form['password-confirm'] = 'confirmation does not match'
form['confirmation'] = 'Terry'
response = form.submit(name='change')
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Change Password' in response
assert 'Password did not match confirm' in response
# fill out the form with valid data and correct password
response = testapp.get('/account/password')
form = response.form
form['password'] = 'Lost in La Mancha'
form['password-confirm'] = 'Lost in La Mancha'
form['confirmation'] = 'Terry'
response = form.submit(name='change')
assert response.location == 'http://localhost/account/changed'
response = response.follow()
active_nav = response.html.find('li', class_='active')
assert active_nav is None
assert 'Your password was changed successfully' in response
assert testapp.login('TerryGilliam', 'Lost in La Mancha')

9
tests/_functional/errors.py

@ -0,0 +1,9 @@
''' functional tests for ordr2.views.errors '''
from . import testappsetup, testapp # noqa: F401
def test_404(testapp): # noqa: F811
''' test the 404 page '''
response = testapp.get('/unknown', status=404)
assert '404' in response

57
tests/_functional/layout.py

@ -0,0 +1,57 @@
''' functional tests for ordr2.templates.layout
The tests for the layout are performed on '/faqs' or '/orders', since these
two urls are accessible by either everyone or all active users
'''
import pytest
from . import testappsetup, testapp # noqa: F401
def test_navbar_no_user(testapp): # noqa: F811
''' test the navigation on top of the page for an unauthenticated user '''
response = testapp.get('/faq')
navbar = response.html.find('nav', class_='navbar-dark')
expected = [
'http://localhost/',
'http://localhost/',
'http://localhost/faq',
'http://localhost/account/register'
]
hrefs = [a['href'] for a in navbar.find_all('a')]
assert expected == hrefs
assert '/orders' not in response
assert 'nav-item dropdown' not in response
@pytest.mark.parametrize( # noqa: F811
'username,password,extras', [
('TerryGilliam', 'Terry', []),
('EricIdle', 'Eric', []),
('TerryJones', 'Terry', ['http://localhost/admin']),
]
)
def test_navbar_with_user(testapp, username, password, extras):
''' test the navigation on top of the page for an authenticated user '''
testapp.login(username, password)
response = testapp.get('/faq')
navbar = response.html.find('nav', class_='navbar-dark')
hrefs = [a['href'] for a in navbar.find_all('a')]
expected = [
'http://localhost/',
'http://localhost/orders',
'http://localhost/faq'
]
expected.extend(extras)
expected.extend([
'#',
'http://localhost/account/logout',
'http://localhost/account/settings',
'http://localhost/account/password'
])
assert expected == hrefs
assert 'nav-item dropdown' in response
assert username in response

21
tests/_functional/pages.py

@ -0,0 +1,21 @@
''' functional tests for ordr2.views.pages '''
from . import testappsetup, testapp # noqa: F401
def test_welcome(testapp): # noqa: F811
''' test the redirects on web root '''
response = testapp.get('/')
assert response.location == 'http://localhost/account/login'
testapp.login('TerryGilliam', 'Terry')
response = testapp.get('/')
assert response.location == 'http://localhost/orders'
def test_faq(testapp): # noqa: F811
''' test the faq page '''
response = testapp.get('/faq')
active = response.html.find('li', class_='active')
assert active.a['href'] == 'http://localhost/faq'

52
tests/events.py

@ -0,0 +1,52 @@
''' 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'
assert notification.send_to == user.email
def test_user_notification_init_send_to_override(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', 'amy@example.com')
assert notification.request == 'request'
assert notification.account == user
assert notification.data == 'data'
assert notification.send_to == 'amy@example.com'
def test_notify_user(app_config): # noqa: F811
''' test the user notification '''
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

1
tests/models/__init__.py

@ -0,0 +1 @@
''' test (sub) package for views '''

255
tests/models/account.py

@ -0,0 +1,255 @@
import pytest
from datetime import datetime, timedelta
from pyramid.testing import DummyRequest
from .. import app_config, dbsession, get_example_user # noqa: F401
@pytest.mark.parametrize(
'key,result', [('NEW', 'role:new'), ('USER', 'role:user')]
)
def test_role_principal(key, result):
''' test the principal representation of a role '''
from ordr.models.account import Role
subject = Role[key]
assert subject.principal == result
@pytest.mark.parametrize(
'key,result', [('NEW', 'New'), ('USER', 'User')]
)
def test_role__str__(key, result):
''' test the string representation of a role '''
from ordr.models.account import Role
subject = Role[key]
assert str(subject) == result
@pytest.mark.parametrize('id_', [1, 2, 5, 123])
def test_user_principal(id_):
''' test the principal representation of a user '''
from ordr.models.account import User
user = User(id=id_)
assert user.principal == f'user:{id_}'
@pytest.mark.parametrize(
'name, principals', [
('UNVALIDATED', ['role:unvalidated']),
('NEW', ['role:new']),
('USER', ['role:user']),
('PURCHASER', ['role:purchaser', 'role:user']),
('ADMIN', ['role:admin', 'role:purchaser', 'role:user']),
('INACTIVE', ['role:inactive']),
]
)
def test_user_principals(name, principals):
''' test all principals of a user '''
from ordr.models.account import User, Role
user = User(id=1, role=Role[name])
expected = ['user:1']
expected.extend(principals)
assert expected == user.principals
@pytest.mark.parametrize(
'name, expected', [
('UNVALIDATED', False),
('NEW', False),
('USER', True),
('PURCHASER', True),
('ADMIN', True),
('INACTIVE', False),
]
)
def test_user_is_active(name, expected):
''' test the calculated property 'active' of a user '''
from ordr.models.account import User, Role
user = User(id=1, role=Role[name])
assert expected == user.is_active
def test_user_set_password():
''' test 'set_password()' method of a user '''
from ordr.models.account import User
from ordr.security import password_context
password_context.update(schemes=['argon2'])
user = User()
assert user.password_hash is None
user.set_password('password')
assert user.password_hash.startswith('$argon2')
@pytest.mark.parametrize(
'password,expected', [
('', False),
('wrong', False),
('password', True),
]
)
def test_user_check_password(password, expected):
''' test the 'check_password()' method of a user '''
from ordr.models.account import User
from ordr.security import password_context
password_context.update(schemes=['argon2'])
hash = ('$argon2i$v=19$m=512,t=2,p=2$'
'YcyZMyak9D7nvFfKmVOq1Q$fnzNh58HWfvxHvRDGjhTqA'
)
user = User(password_hash=hash)
assert user.check_password(password) == expected
def test_user_check_password_updates_old_sheme():
''' test that 'check_password()' updates the hash off an old scheme '''
from ordr.models.account import User
from ordr.security import password_context
password_context.update(
schemes=['argon2', 'bcrypt'],
default='argon2',
deprecated='auto'
)
old_hash = '$2b$12$6ljSfpLaXBeEVOeaP1scUe6IAa0cztM.UBbjc1PdrI4j0vwgoYgpi'
user = User(password_hash=old_hash)
assert user.check_password('password')
assert user.password_hash.startswith('$argon2')
assert user.check_password('password')
def test_user__str__():
''' test the string representation of a user '''
from ordr.models.account import User
user = User(username='Eric Idle')
assert str(user) == 'Eric Idle'
def test_user_issue_token(app_config): # noqa: F811
''' test the 'issue_token()' method of a user '''
from ordr.models.account import User, Token, TokenSubject
request = DummyRequest()
user = User()
token = user.issue_token(request, TokenSubject.REGISTRATION, {'foo': 1})
assert isinstance(token, Token)
assert token.hash is not None
assert token.subject == TokenSubject.REGISTRATION
assert token.payload == {'foo': 1}
assert token.owner == user
def test_token_issue_token(app_config): # noqa: F811
''' test the 'issue()' class method of the token class '''
from ordr.models.account import User, Token, TokenSubject
request = DummyRequest()
user = User()
token = Token.issue(request, user, TokenSubject.REGISTRATION, {'foo': 1})
expected_expires = datetime.utcnow() + timedelta(minutes=5)
assert isinstance(token, Token)
assert token.hash is not None
assert token.subject == TokenSubject.REGISTRATION
assert token.payload == {'foo': 1}
assert token.owner == user
assert token.expires.timestamp() == pytest.approx(
expected_expires.timestamp(),
abs=1
)
@pytest.mark.parametrize( # noqa: F811
'subject,delta', [('REGISTRATION', 5), ('RESET_PASSWORD', 10)]
)
def test_token_issue_token_time_from_settings(app_config, subject, delta):
''' test that 'issue()' uses the exiration time from setting '''
from ordr.models.account import User, Token, TokenSubject
request = DummyRequest()
request.registry.settings['token_expiry.reset_password'] = 10
user = User()
token_subject = TokenSubject[subject]
token = Token.issue(request, user, token_subject, None)
expected_expires = datetime.utcnow() + timedelta(minutes=delta)
assert token.expires.timestamp() == pytest.approx(
expected_expires.timestamp(),
abs=1
)
@pytest.mark.parametrize('use_subject', [True, False]) # noqa: F811
def test_registration_token_retrieve_ok(dbsession, use_subject):
''' test 'retrieve()' class method returns token instance '''
from ordr.models.account import Role, Token, TokenSubject
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
subject = TokenSubject.REGISTRATION if use_subject else None
result = Token.retrieve(request, token.hash, subject=subject)
assert result == token
def test_registration_token_retrieve_not_found(dbsession): # noqa: F811
''' test 'retrieve()' class method returns None if token not found '''
from ordr.models.account import Role, Token, TokenSubject
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
result = Token.retrieve(request, 'unknown hash')
assert result is None
def test_registration_token_retrieve_wrong_subject(dbsession): # noqa: F811
''' test 'retrieve()' class method returns None if wrong subject used '''
from ordr.models.account import Role, Token, TokenSubject
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
result = Token.retrieve(
request,
token.hash,
subject=TokenSubject.RESET_PASSWORD
)
assert result is None
def test_registration_token_expired_raises_exception(dbsession): # noqa: F811
''' test 'retrieve()' class method raises exception if token is expired '''
from ordr.models.account import Role, Token, TokenSubject, TokenExpired
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.REGISTRATION)
token.expires = datetime.utcnow() - timedelta(weeks=1)
dbsession.add(user)
dbsession.flush()
with pytest.raises(TokenExpired):
Token.retrieve(request, token.hash)
dbsession.flush()
assert dbsession.query(Token).count() == 0

38
tests/models/meta.py

@ -0,0 +1,38 @@
import pytest
@pytest.mark.parametrize(
'value,expected', [
(None, None),
([1, 2, 3], '[1, 2, 3]'),
({'a': 1, 'b': 2}, '{"a": 1, "b": 2}'),
]
)
def test_json_encoder_bind(value, expected):
''' test encoding json '''
from ordr.models.meta import JsonEncoder
encoder = JsonEncoder()
assert encoder.process_bind_param(value, None) == expected
@pytest.mark.parametrize(
'value,expected', [
(None, None),
('[1, 2, 3]', [1, 2, 3]),
('{"a": 1, "b":2}', {'a': 1, 'b': 2}),
]
)
def test_json_encoder_result(value, expected):
''' test decoding json '''
from ordr.models.meta import JsonEncoder
encoder = JsonEncoder()
assert encoder.process_result_value(value, None) == expected
@pytest.mark.parametrize('value', [None, [1, 2, 3], {'a': 1, 'b': 2}])
def test_json_encoder_bind_and_result(value):
''' encoding and later decoding json should provide not change value '''
from ordr.models.meta import JsonEncoder
encoder = JsonEncoder()
result = encoder.process_bind_param(value, None)
assert encoder.process_result_value(result, None) == value

1
tests/resources/__init__.py

@ -0,0 +1 @@
''' test package for resources '''

323
tests/resources/account.py

@ -0,0 +1,323 @@
''' Tests for the account resources '''
import pytest
from pyramid.testing import DummyRequest, DummyResource
from .. import app_config, dbsession, get_example_user # noqa: F401
def test_registration_token_acl():
''' test access controll list for RegistrationTokenResource '''
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import RegistrationTokenResource
parent = DummyResource(request='request')
resource = RegistrationTokenResource('name', parent)
assert resource.__acl__() == [(Allow, Everyone, 'register'), DENY_ALL]
def test_registration_acl():
''' test access controll list for RegistrationResource '''
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import RegistrationResource
parent = DummyResource(request='request')
resource = RegistrationResource('a name', parent)
assert resource.__acl__() == [(Allow, Everyone, 'register'), DENY_ALL]
def test_registration_get_registration_form():
''' test 'get_registration_form()' method of RegistrationResource '''
from ordr.resources.account import RegistrationResource
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = RegistrationResource('a name', 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'
def test_registration_getitem_found(dbsession): # noqa: F811
''' test '__getitem__()' method returns child resource '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import (
RegistrationResource,
RegistrationTokenResource
)
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = RegistrationResource('a name', parent)
result = resource[token.hash]
assert isinstance(result, RegistrationTokenResource)
assert result.__name__ == token.hash
assert result.__parent__ == resource
assert result.model == token
def test_registration_getitem_not_found(dbsession): # noqa: F811
''' test '__getitem__()' method raises KeyError '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import RegistrationResource
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = RegistrationResource('a name', parent)
with pytest.raises(KeyError):
resource['unknown hash']
def test_password_reset_token_acl():
''' test access controll list for PasswordResetTokenResource '''
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import PasswordResetTokenResource
parent = DummyResource(request='request')
resource = PasswordResetTokenResource('name', parent)
assert resource.__acl__() == [(Allow, Everyone, 'reset'), DENY_ALL]
def test_password_reset_token_get_reset_form():
''' test the setup of the password reset form'''
from ordr.resources.account import PasswordResetTokenResource
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = PasswordResetTokenResource('some name', parent)
form = resource.get_reset_form()
assert isinstance(form, deform.Form)
assert len(form.buttons) == 2
assert form.buttons[0].title == 'Set New Password'
assert form.buttons[1].title == 'Cancel'
def test_password_reset_acl():
''' test access controll list for PasswordResetResource '''
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources.account import PasswordResetResource
parent = DummyResource(request='request')
resource = PasswordResetResource('a name', parent)
assert resource.__acl__() == [(Allow, Everyone, 'reset'), DENY_ALL]
def test_password_reset_getitem_found(dbsession): # noqa: F811
''' test '__getitem__()' method returns child resource '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import (
PasswordResetResource,
PasswordResetTokenResource
)
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.RESET_PASSWORD)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = PasswordResetResource('a name', parent)
result = resource[token.hash]
assert isinstance(result, PasswordResetTokenResource)
assert result.__name__ == token.hash
assert result.__parent__ == resource
assert result.model == token
def test_password_reset_getitem_not_found(dbsession): # noqa: F811
''' test '__getitem__()' method raises KeyError '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import PasswordResetResource
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
user.issue_token(request, TokenSubject.RESET_PASSWORD)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = PasswordResetResource('a name', parent)
with pytest.raises(KeyError):
resource['unknown hash']
def test_change_email_token_acl(dbsession): # noqa: F811
''' test access controll list for PasswordResetTokenResource '''
from pyramid.security import Allow, DENY_ALL
from ordr.models.account import Role, Token, TokenSubject
from ordr.resources.account import ChangeEmailTokenResource
request = DummyRequest()
user = get_example_user(Role.USER)
dbsession.add(user)
user.issue_token(request, TokenSubject.CHANGE_EMAIL)
dbsession.flush()
token = dbsession.query(Token).first()
parent = DummyResource(request='request')
resource = ChangeEmailTokenResource('name', parent, model=token)
assert resource.__acl__() == [(Allow, 'user:3', 'edit'), DENY_ALL]
def test_account_resource_set_model_from_request():
''' test access controll list for PasswordResetResource '''
from ordr.resources.account import AccountResource
request = DummyRequest(user='Amy McDonald')
parent = DummyResource(request=request)
resource = AccountResource('a name', parent)
assert resource.model == 'Amy McDonald'
def test_account_resource_acl():
''' test access controll list for PasswordResetResource '''
from pyramid.security import (
Allow,
Everyone,
Authenticated,
DENY_ALL
)
from ordr.resources.account import AccountResource
request = DummyRequest()
parent = DummyResource(request=request)
resource = AccountResource('a name', parent)
assert resource.__acl__() == [
(Allow, Everyone, 'view'),
(Allow, Everyone, 'login'),
(Allow, Everyone, 'logout'),
(Allow, Everyone, 'register'),
(Allow, Everyone, 'reset'),
(Allow, Authenticated, 'edit'),
DENY_ALL
]
@pytest.mark.parametrize('key', ['register', 'forgot']) # noqa: F811
def test_account_resource_getitem_static(dbsession, key):
''' test '__getitem__()' method returns static resources '''
from ordr.resources.account import (
AccountResource,
PasswordResetResource,
RegistrationResource
)
request = DummyRequest(dbsession=dbsession)
parent = DummyResource(request=request)
resource = AccountResource('some name', parent)
result = resource[key]
if key == 'register':
assert isinstance(result, RegistrationResource)
elif key == 'forgot':
assert isinstance(result, PasswordResetResource)
def test_account_resource_getitem_token(dbsession): # noqa: F811
''' test '__getitem__()' method returns child resource '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import (
AccountResource,
ChangeEmailTokenResource
)
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
token = user.issue_token(request, TokenSubject.CHANGE_EMAIL)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = AccountResource('a name', parent)
result = resource[token.hash]
assert isinstance(result, ChangeEmailTokenResource)
assert result.__name__ == token.hash
assert result.__parent__ == resource
assert result.model == token
def test_account_resource_getitem_not_found(dbsession): # noqa: F811
''' test '__getitem__()' method raises KeyError '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import AccountResource
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.NEW)
user.issue_token(request, TokenSubject.CHANGE_EMAIL)
dbsession.add(user)
dbsession.flush()
parent = DummyResource(request=request)
resource = AccountResource('a name', parent)
with pytest.raises(KeyError):
resource['unknown hash']
def test_account_resource_get_settings_form():
''' test the setup of the settings form'''
from ordr.resources.account import AccountResource
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = AccountResource('some name', parent)
form = resource.get_settings_form()
assert isinstance(form, deform.Form)
assert len(form.buttons) == 2
assert form.buttons[0].title == 'Change Settings'
assert form.buttons[1].title == 'Cancel'
def test_account_resource_get_password_form():
''' test the setup of the change password form'''
from ordr.resources.account import AccountResource
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = AccountResource('some name', parent)
form = resource.get_password_form()
assert isinstance(form, deform.Form)
assert len(form.buttons) == 2
assert form.buttons[0].title == 'Change Password'
assert form.buttons[1].title == 'Cancel'

94
tests/resources/base_child_resource.py

@ -0,0 +1,94 @@
''' Tests for the root resource '''
import pytest
from pyramid.testing import DummyRequest, DummyResource
def test_base_child_init():
''' test initilization of BaseChildResource '''
from ordr.resources.helpers import BaseChildResource
parent = DummyResource(request='some request')
resource = BaseChildResource(name='a name', parent=parent)
assert resource.__name__ == 'a name'
assert resource.__parent__ == parent
assert resource.request == 'some request'
def test_base_child_acl():
''' test access controll list of BaseChildResource '''
from ordr.resources.helpers import BaseChildResource
parent = DummyResource(request='some request')
resource = BaseChildResource(name='a name', parent=parent)
with pytest.raises(NotImplementedError):
resource.__acl__()
def test_base_child_prepare_form():
''' test '_prepare_form()' method of BaseChildResource '''
from ordr.resources.helpers import BaseChildResource
from ordr.schemas.account import RegistrationSchema
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = BaseChildResource('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():
''' test '_prepare_form()' method sets correct url '''
from ordr.resources.helpers import BaseChildResource
from ordr.schemas.account import RegistrationSchema
request = DummyRequest()
parent = DummyResource(request=request)
resource = BaseChildResource('a name', parent)
form = resource._prepare_form(RegistrationSchema, action='/foo')
assert form.action == '/foo'
def test_base_child_prepare_form_settings():
''' test '_prepare_form()' method uses additional settings '''
from ordr.resources.helpers import BaseChildResource
from ordr.schemas.account import RegistrationSchema
import deform
request = DummyRequest()
parent = DummyResource(request=request)
resource = BaseChildResource('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)
def test_base_child_prepare_form_prefill():
''' test '_prepare_form()' method can prefill a form '''
from ordr.resources.helpers import BaseChildResource
from ordr.schemas.account import RegistrationSchema
request = DummyRequest()
parent = DummyResource(request=request)
resource = BaseChildResource('a name', parent)
prefill = {
'first_name': 'John',
'last_name': 'Doe',
'email': 'johndoe@example.com'
}
form = resource._prepare_form(RegistrationSchema, prefill=prefill)
assert form['first_name'].cstruct == 'John'
assert form['last_name'].cstruct == 'Doe'
assert form['email'].cstruct == 'johndoe@example.com'

48
tests/resources/root.py

@ -0,0 +1,48 @@
''' Tests for the root resource '''
import pytest
from ordr.resources.account import AccountResource
def test_root_init():
''' test RootResource initialization '''
from ordr.resources import RootResource
root = RootResource('request')
assert root.__name__ is None
assert root.__parent__ is None
assert root.request == 'request'
def test_root_acl():
''' test access controll list for RootResource '''
from pyramid.security import Allow, Everyone, DENY_ALL
from ordr.resources import RootResource
root = RootResource(None)
assert root.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL]
@pytest.mark.parametrize(
'key,resource_class', [
('account', AccountResource)
]
)
def test_root_getitem(key, resource_class):
''' test '__getitem__()' method of RootResource '''
from ordr.resources import RootResource
root = RootResource(None)
child = root[key]
assert isinstance(child, resource_class)
assert child.__name__ == key
assert child.__parent__ == root
assert child.request == root.request
def test_root_getitem_raises_error():
''' test '__getitem__()' method raises KeyError '''
from ordr.resources import RootResource
root = RootResource(None)
with pytest.raises(KeyError):
root['unknown child name']

28
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, action='/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'

165
tests/schemas/validators.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.validators 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.validators 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.validators 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.validators 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.validators 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.validators 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.validators 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.validators 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.validators 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.validators 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

132
tests/security.py

@ -0,0 +1,132 @@
import pytest
from pyramid.testing import DummyRequest
from . import app_config, dbsession, get_example_user # noqa: F401
def test_crypt_context_to_settings():
''' test the transformation of .ini styles from pyramid to passlib '''
from ordr.security import crypt_context_settings_to_string
settings = {
'no_prefix': 'should not appear',
'prefix.something': 'left unchanged',
'prefix.schemes': 'adjust list',
'prefix.depreceated': 'do, not, adjust, this, list'
}
result = crypt_context_settings_to_string(settings, 'prefix.')
expected_lines = {
'[passlib]',
'something = left unchanged',
'schemes = adjust,list',
'depreceated = do, not, adjust, this, list',
}
assert set(result.split('\n')) == expected_lines
def test_authentication_policy_authenticated_user_id_no_user():
''' test 'authenticated_userid()' returns None if no user is logged in '''
from ordr.security import AuthenticationPolicy
ap = AuthenticationPolicy('')
request = DummyRequest(user=None)
assert ap.authenticated_userid(request) is None
def test_authentication_policy_authenticated_user_id_with_user():
''' test 'authenticated_userid()' returns id if user is logged in '''
from ordr.security import AuthenticationPolicy
from ordr.models import User
ap = AuthenticationPolicy('')
request = DummyRequest(user=User(id=123))
assert ap.authenticated_userid(request) == 123
def test_authentication_policy_effective_principals_no_user():
''' test 'effective_principals()' if not user is logged in '''
from ordr.security import AuthenticationPolicy
from pyramid.security import Everyone
request = DummyRequest(user=None)
ap = AuthenticationPolicy('')
result = ap.effective_principals(request)
assert result == [Everyone]
def test_authentication_policy_effective_principals_with_user():
''' test 'effective_principals()' if user is logged in '''
from ordr.security import AuthenticationPolicy
from ordr.models import User, Role
from pyramid.security import Authenticated, Everyone
ap = AuthenticationPolicy('')
user = User(id=123, role=Role.PURCHASER)
request = DummyRequest(user=user)
result = ap.effective_principals(request)
expected = [
Everyone,
Authenticated,
'user:123',
'role:purchaser',
'role:user'
]
assert result == expected
@pytest.mark.parametrize( # noqa: F811
'uauid,role_name', [
(3, 'USER'),
(4, 'PURCHASER'),
(5, 'ADMIN'),
]
)
def test_get_user_returns_user(dbsession, uauid, role_name):
''' test 'get_user()' returns active user '''
from ordr.security import get_user
from ordr.models import Role
# this is a dirty hack, but DummyRequest does not accept setting an
# unauthenticated_userid
from pyramid.testing import DummyResource
request = DummyResource(unauthenticated_userid=uauid, dbsession=dbsession)
user_role = Role[role_name]
user = get_example_user(user_role)
dbsession.add(user)
dbsession.flush()
assert get_user(request) == user
@pytest.mark.parametrize( # noqa: F811
'uauid,role_name', [
(1, 'UNVALIDATED'),
(2, 'NEW'),
(6, 'INACTIVE'),
(2, 'USER'),
(None, 'USER'),
]
)
def test_get_user_returns_none(dbsession, uauid, role_name):
''' test 'get_user()' returns None for an inactive user '''
from ordr.security import get_user
from ordr.models import Role
# this is a dirty hack, but DummyRequest does not accept setting an
# unauthenticated_userid
from pyramid.testing import DummyResource
request = DummyResource(unauthenticated_userid=uauid, dbsession=dbsession)
user_role = Role[role_name]
user = get_example_user(user_role)
dbsession.add(user)
dbsession.flush()
assert get_user(request) is None

1
tests/views/__init__.py

@ -0,0 +1 @@
# package

22
tests/views/account/__init__.py

@ -0,0 +1,22 @@
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest
from ... import ( # noqa: F401
app_config,
dbsession,
get_example_user,
get_post_request
)
# test for account resource root
def test_account_redirect(dbsession): # noqa: F811
''' redirect on root of account resource '''
from ordr.views.account import account
request = DummyRequest(dbsession=dbsession)
result = account(None, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'

241
tests/views/account/forgotten_password.py

@ -0,0 +1,241 @@
import deform
import pytest
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest, DummyResource
from ... import ( # noqa: F401
app_config,
dbsession,
get_example_user,
get_post_request
)
def test_forgotten_password_form():
''' test the view for the forgotten password form '''
from ordr.resources.account import PasswordResetResource
from ordr.views.account import forgotten_password_form
request = DummyRequest()
parent = DummyResource(request=request)
context = PasswordResetResource(name=None, parent=parent)
result = forgotten_password_form(context, None)
assert result == {'formerror': False}
@pytest.mark.parametrize( # noqa: F811
'identifier',
['TerryGilliam', 'gilliam@example.com', 'Gilliam@Example.com']
)
def test_forgotten_password_processing_ok(dbsession, identifier):
''' test the processing of the forgotten password form '''
from ordr.models.account import Role, TokenSubject
from ordr.resources.account import PasswordResetResource
from ordr.views.account import (
forgotten_password_form_processing
)
user = get_example_user(Role.USER)
dbsession.add(user)
dbsession.flush()
post_data = {
'identifier': identifier,
'send_mail': 'send_mail',
}
request = DummyRequest(dbsession=dbsession, POST=post_data)
parent = DummyResource(request=request)
context = PasswordResetResource(name=None, parent=parent)
result = forgotten_password_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//verify'
# a token should be created
token = user.tokens[0]
assert token.subject == TokenSubject.RESET_PASSWORD
# 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
@pytest.mark.parametrize( # noqa: F811
'identifier',
['', 'GrahamChapman', 'unknown@example.com']
)
def test_forgotten_password_processing_not_ok(dbsession, identifier):
''' test error processing of the forgotten password form '''
from ordr.models.account import Role, Token
from ordr.resources.account import PasswordResetResource
from ordr.views.account import (
forgotten_password_form_processing
)
user = get_example_user(Role.UNVALIDATED)
dbsession.add(user)
dbsession.flush()
post_data = {
'identifier': identifier,
'send_mail': 'send_mail',
}
request = DummyRequest(dbsession=dbsession, POST=post_data)
parent = DummyResource(request=request)
context = PasswordResetResource(name=None, parent=parent)
result = forgotten_password_form_processing(context, request)
assert result == {'formerror': True}
assert dbsession.query(Token).count() == 0
def test_forgotten_password_processing_cancel(dbsession): # noqa: F811
''' test the canceling of the forgotten password form '''
from ordr.models.account import Token
from ordr.resources.account import PasswordResetResource
from ordr.views.account import (
forgotten_password_form_processing
)
post_data = {
'identifier': 'TerryGilliam',
'cancel': 'cancel',
}
request = DummyRequest(dbsession=dbsession, POST=post_data)
parent = DummyResource(request=request)
context = PasswordResetResource(name=None, parent=parent)
result = forgotten_password_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'
assert dbsession.query(Token).count() == 0
def test_forgotten_password_verify_email():
''' test the message view for check your email '''
from ordr.views.account import forgotten_password_verify_email
result = forgotten_password_verify_email(None, None)
assert result == {}
def test_forgotten_password_completed():
''' test the view for a completed reset process '''
from ordr.views.account import forgotten_password_completed
result = forgotten_password_completed(None, None)
assert result == {}
def test_reset_password_form():
''' test reset password form view '''
from ordr.resources.account import PasswordResetTokenResource
from ordr.schemas.account import ResetPasswordSchema
from ordr.views.account import reset_password_form
request = DummyRequest()
parent = DummyResource(request=request)
context = PasswordResetTokenResource(name=None, parent=parent)
result = reset_password_form(context, None)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, ResetPasswordSchema)
def test_reset_password_form_processing_valid(dbsession): # noqa: F811
''' test reset password form processing '''
from ordr.models.account import User, Role, Token, TokenSubject
from ordr.resources.account import PasswordResetTokenResource
from ordr.views.account import reset_password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'Lost in La Mancha',
'password-confirm': 'Lost in La Mancha',
'__end__': 'password:mapping',
'change': 'Set New Password'
}
request = get_post_request(data, dbsession=dbsession)
user = get_example_user(Role.USER)
dbsession.add(user)
user.issue_token(request, TokenSubject.RESET_PASSWORD)
dbsession.flush()
token = dbsession.query(Token).first()
parent = DummyResource(request=request)
context = PasswordResetTokenResource(name=None, parent=parent, model=token)
result = reset_password_form_processing(context, request)
# return value of function call
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com/completed'
# password of the user should be updated
user = dbsession.query(User).filter_by(username='TerryGilliam').first()
assert user.check_password('Lost in La Mancha')
token_count = dbsession.query(Token).count()
assert token_count == 0
def test_reset_password_form_processing_invalid_data(dbsession): # noqa: F811
''' test reset password form processing '''
from ordr.models.account import Role, Token, TokenSubject
from ordr.resources.account import PasswordResetTokenResource
from ordr.schemas.account import ResetPasswordSchema
from ordr.views.account import reset_password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'does not match',
'password-confirm': 'the confirmation',
'__end__': 'password:mapping',
'change': 'Set New Password'
}
request = get_post_request(data, dbsession=dbsession)
user = get_example_user(Role.USER)
dbsession.add(user)
user.issue_token(request, TokenSubject.RESET_PASSWORD)
dbsession.flush()
token = dbsession.query(Token).first()
parent = DummyResource(request=request)
context = PasswordResetTokenResource(name=None, parent=parent, model=token)
result = reset_password_form_processing(context, request)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, ResetPasswordSchema)
def test_reset_password_form_processing_cancel(dbsession): # noqa: F811
''' test reset password form processing '''
from ordr.models.account import Role, Token, TokenSubject
from ordr.resources.account import PasswordResetTokenResource
from ordr.views.account import reset_password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'Lost in La Mancha',
'password-confirm': 'Lost in La Mancha',
'__end__': 'password:mapping',
'cancel': 'Cancel'
}
request = get_post_request(data, dbsession=dbsession)
user = get_example_user(Role.USER)
dbsession.add(user)
user.issue_token(request, TokenSubject.RESET_PASSWORD)
dbsession.flush()
token = dbsession.query(Token).first()
parent = DummyResource(request=request)
context = PasswordResetTokenResource(name=None, parent=parent, model=token)
result = reset_password_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'

95
tests/views/account/login_logout.py

@ -0,0 +1,95 @@
import pytest
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest, DummyResource
from ordr.models.account import Role
from ... import ( # noqa: F401
app_config,
dbsession,
get_example_user,
get_post_request
)
def test_login():
''' test the view for the login form '''
from ordr.views.account import login
context = DummyResource(nav_active=None)
result = login(context, None)
assert result == {'loginerror': False}
assert context.nav_active == 'welcome'
@pytest.mark.parametrize( # noqa: F811
'role', [Role.USER, Role.PURCHASER, Role.ADMIN]
)
def test_check_login_ok(dbsession, role):
''' test the processing of the login form with valid credentials '''
from ordr.views.account import check_login
user = get_example_user(role)
dbsession.add(user)
post_data = {'username': user.username, 'password': user.first_name}
request = DummyRequest(dbsession=dbsession, POST=post_data)
context = DummyResource(nav_active=None)
result = check_login(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'
@pytest.mark.parametrize( # noqa: F811
'role', [Role.UNVALIDATED, Role.NEW, Role.INACTIVE]
)
def test_check_login_not_activated(dbsession, role):
''' test the processing of the login form with an inactive user '''
from ordr.views.account import check_login
user = get_example_user(role)
dbsession.add(user)
post_data = {'username': user.username, 'password': user.first_name}
request = DummyRequest(dbsession=dbsession, POST=post_data)
context = DummyResource(nav_active=None)
result = check_login(context, request)
assert result == {'loginerror': True}
assert context.nav_active == 'welcome'
@pytest.mark.parametrize( # noqa: F811
'username,password', [
('', ''),
('TerryGilliam', ''),
('', 'Terry'),
('TerryGilliam', 'wrong password'),
('wrong username', 'Terry'),
]
)
def test_check_login_invalid_credentials(dbsession, username, password):
''' test the processing of the login form with invalid credentials '''
from ordr.views.account import check_login
user = get_example_user(Role.USER)
dbsession.add(user)
post_data = {'username': username, 'password': password}
request = DummyRequest(dbsession=dbsession, POST=post_data)
context = DummyResource(nav_active=None)
result = check_login(context, request)
assert result == {'loginerror': True}
assert context.nav_active == 'welcome'
def test_logout():
''' test the logout view '''
from ordr.views.account import logout
request = DummyRequest()
result = logout(None, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'

131
tests/views/account/registration.py

@ -0,0 +1,131 @@
import deform
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest, DummyResource
from ... import ( # noqa: F401
app_config,
dbsession,
get_example_user,
get_post_request
)
REGISTRATION_FORM_DATA = {
'username': 'AmyMcDonald',
'first_name': 'Amy',
'last_name': 'McDonald',
'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():
''' test the view for the registration form '''
from ordr.resources.account import RegistrationResource
from ordr.schemas.account import RegistrationSchema
from ordr.views.account import registration_form
request = DummyRequest()
parent = DummyResource(request=request)
context = RegistrationResource(name=None, parent=parent)
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
''' test processing the registration form with valid data '''
from ordr.models.account import User, Role, TokenSubject
from ordr.resources.account import RegistrationResource
from ordr.views.account import registration_form_processing
data = REGISTRATION_FORM_DATA.copy()
request = get_post_request(data, dbsession=dbsession)
parent = DummyResource(request=request)
context = RegistrationResource(name=None, parent=parent)
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
''' test processing registration form with invalid data '''
from ordr.views.account 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(data, dbsession=dbsession)
parent = DummyResource(request=request)
context = RegistrationResource(name=None, parent=parent)
result = registration_form_processing(context, request)
assert result['form'].error is not None
def test_registration_form_no_create_button(dbsession): # noqa: F811
''' test processing registration form, create button not clicked '''
from ordr.views.account import registration_form_processing
from ordr.resources.account import RegistrationResource
data = REGISTRATION_FORM_DATA.copy()
data.pop('create')
request = get_post_request(data, dbsession=dbsession)
parent = DummyResource(request=request)
context = RegistrationResource(name=None, parent=parent)
result = registration_form_processing(context, request)
assert result.location == 'http://example.com//'
def test_registration_verify_email():
''' test the view displaying that a verifcation email has been sent '''
from ordr.views.account import registration_verify_email
result = registration_verify_email(None, None)
assert result == {}
def test_registration_completed(dbsession): # noqa: F811
''' test the view for the completed registration process '''
from ordr.models.account import User, Role, Token, TokenSubject
from ordr.views.account import registration_completed
request = DummyRequest(dbsession=dbsession)
user = get_example_user(Role.UNVALIDATED)
user.issue_token(request, TokenSubject.REGISTRATION)
dbsession.add(user)
dbsession.flush()
token = user.tokens[0]
context = DummyResource(model=token)
result = registration_completed(context, request)
assert result == {}
assert user.role == Role.NEW
assert dbsession.query(Token).count() == 0
assert dbsession.query(User).count() == 1

300
tests/views/account/settings.py

@ -0,0 +1,300 @@
import deform
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest, DummyResource
from ... import ( # noqa: F401
app_config,
dbsession,
get_example_user,
get_post_request
)
def test_settings_form():
''' tests for displaying the settings form '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.schemas.account import SettingsSchema
from ordr.views.account import settings_form
user = get_example_user(Role.USER)
request = DummyRequest(user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
result = settings_form(context, request)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, SettingsSchema)
def test_settings_form_processing_valid_data(dbsession): # noqa: F811
''' tests for processing the settings form
The data is valid, but no email change requested
'''
from ordr.models.account import Role, Token, User
from ordr.resources.account import AccountResource
from ordr.views.account import settings_form_processing
data = {
'username': 'TerryG',
'first_name': 'Amy',
'last_name': 'McDonald',
'email': 'gilliam@example.com',
'confirmation': 'Terry',
'change': 'Change Settings'
}
user = get_example_user(Role.USER)
dbsession.add(user)
dbsession.flush()
request = get_post_request(data=data, dbsession=dbsession, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
request.context = context
result = settings_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'
account = dbsession.query(User).first()
assert account.username == 'TerryGilliam'
assert account.first_name == 'Amy'
assert account.last_name == 'McDonald'
assert account.email == 'gilliam@example.com'
assert dbsession.query(Token).count() == 0
def test_settings_form_processing_mail_change(dbsession): # noqa: F811
''' tests for processing the settings form
The data is valid and an email change is requested
'''
from ordr.models.account import Role, Token, TokenSubject, User
from ordr.resources.account import AccountResource
from ordr.views.account import settings_form_processing
data = {
'username': 'TerryG',
'first_name': 'Amy',
'last_name': 'McDonald',
'email': 'amy@example.com',
'confirmation': 'Terry',
'change': 'Change Settings'
}
user = get_example_user(Role.USER)
dbsession.add(user)
request = get_post_request(data=data, dbsession=dbsession, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
request.context = context
result = settings_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//verify'
account = dbsession.query(User).first()
assert account.username == 'TerryGilliam'
assert account.first_name == 'Amy'
assert account.last_name == 'McDonald'
assert account.email == 'gilliam@example.com'
token = dbsession.query(Token).first()
assert token.subject == TokenSubject.CHANGE_EMAIL
assert token.payload == {'email': 'amy@example.com'}
# 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_settings_form_processing_invalid_data(dbsession): # noqa: F811
''' tests for processing the settings form with invalid data '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.schemas.account import SettingsSchema
from ordr.views.account import settings_form_processing
data = {
'username': 'TerryG',
'first_name': 'Amy',
'last_name': 'McDonald',
'email': 'this is not an email address',
'confirmation': 'Terry',
'change': 'Change Settings'
}
user = get_example_user(Role.USER)
dbsession.add(user)
request = get_post_request(data=data, dbsession=dbsession, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
request.context = context
result = settings_form_processing(context, request)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, SettingsSchema)
def test_settings_form_processing_cancel(dbsession): # noqa: F811
''' tests for processing the settings form with invalid data '''
from ordr.models.account import Role, User
from ordr.resources.account import AccountResource
from ordr.views.account import settings_form_processing
data = {
'username': 'TerryG',
'first_name': 'Amy',
'last_name': 'McDonald',
'email': 'this is not an email address',
'confirmation': 'Terry',
'cancel': 'cancel'
}
user = get_example_user(Role.USER)
dbsession.add(user)
request = get_post_request(data=data, dbsession=dbsession, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
request.context = context
result = settings_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'
account = dbsession.query(User).first()
assert account.first_name == 'Terry'
def test_verify_email_change(dbsession): # noqa: F811
''' tests for processing the change password form '''
from ordr.models.account import Role, Token, TokenSubject
from ordr.views.account import verify_email_change
user = get_example_user(Role.USER)
request = DummyRequest(dbsession=dbsession, user=user)
user.issue_token(
request,
TokenSubject.CHANGE_EMAIL,
{'email': 'amy@example.com'}
)
dbsession.add(user)
dbsession.flush()
token = dbsession.query(Token).first()
context = DummyResource(model=token)
result = verify_email_change(context, request)
assert result == {}
assert user.email == 'amy@example.com'
assert dbsession.query(Token).count() == 0
def test_password_form():
''' tests for displaying the change password form '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.schemas.account import ChangePasswordSchema
from ordr.views.account import password_form
user = get_example_user(Role.USER)
request = DummyRequest(user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
result = password_form(context, request)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, ChangePasswordSchema)
def test_password_form_processing_valid(dbsession): # noqa: F811
''' tests for processing the change password form '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.views.account import password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'Make Amy McDonald A Rich Girl Fund',
'password-confirm': 'Make Amy McDonald A Rich Girl Fund',
'__end__': 'password:mapping',
'confirmation': 'Terry',
'change': 'Change Password'
}
user = get_example_user(Role.USER)
request = get_post_request(data=data, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
result = password_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//changed'
assert not user.check_password('Terry')
assert user.check_password('Make Amy McDonald A Rich Girl Fund')
def test_password_form_processing_invalid(dbsession): # noqa: F811
''' tests for processing the change password form '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.schemas.account import ChangePasswordSchema
from ordr.views.account import password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'Make Amy McDonald A Rich Girl Fund',
'password-confirm': 'Make Amy McDonald A Rich Girl Fund',
'__end__': 'password:mapping',
'confirmation': 'not the right password for confirmation',
'change': 'Change Password'
}
user = get_example_user(Role.USER)
request = get_post_request(data=data, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
result = password_form_processing(context, request)
form = result['form']
assert isinstance(form, deform.Form)
assert isinstance(form.schema, ChangePasswordSchema)
assert user.check_password('Terry')
def test_password_form_processing_cancel(dbsession): # noqa: F811
''' tests canceling the change password form '''
from ordr.models.account import Role
from ordr.resources.account import AccountResource
from ordr.views.account import password_form_processing
data = {
'__start__': 'password:mapping',
'password': 'Make Amy McDonald A Rich Girl Fund',
'password-confirm': 'Make Amy McDonald A Rich Girl Fund',
'__end__': 'password:mapping',
'confirmation': 'Terry',
'cancel': 'cancel'
}
user = get_example_user(Role.USER)
request = get_post_request(data=data, user=user)
parent = DummyResource(request=request)
context = AccountResource(None, parent)
result = password_form_processing(context, request)
assert isinstance(result, HTTPFound)
assert result.location == 'http://example.com//'
assert user.check_password('Terry')
def test_password_changed():
''' show password has changed message '''
from ordr.views.account import password_changed
result = password_changed(None, None)
assert result == {}

23
tests/views/errors.py

@ -0,0 +1,23 @@
from pyramid.testing import DummyRequest
def test_404():
''' test the file not found view '''
from ordr.views.errors import notfound_view
request = DummyRequest()
result = notfound_view(None, request)
assert result == {}
assert request.response.status == '404 Not Found'
def test_token_expired():
''' test the token expired found view '''
from ordr.views.errors import token_expired
request = DummyRequest()
result = token_expired(None, request)
assert result == {}
assert request.response.status == '410 Gone'

29
tests/views/pages.py

@ -0,0 +1,29 @@
import pytest
from pyramid.httpexceptions import HTTPFound
from pyramid.testing import DummyRequest
from .. import app_config, dbsession, get_example_user # noqa: F401
@pytest.mark.parametrize(
'user,location',
[(None, '/account/login'), ('someone', '/orders')]
)
def test_welcome(user, location):
''' test redirects on web root '''
from ordr.views.pages import welcome
request = DummyRequest(user=user)
result = welcome(None, request)
assert isinstance(result, HTTPFound)
assert result.location == f'http://example.com/{location}'
def test_faq():
''' test the view for the faq page '''
from ordr.views.pages import faq
result = faq(None, None)
assert result == {}