From 388cf106c2b1653599fb31b45044820fb55af8ec Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Sat, 19 Apr 2014 00:55:03 -0400 Subject: [PATCH] Add PasswordType and make __init__ less verbose --- .../{{cookiecutter.app_name}}/database.py | 57 ++++++++++++++++++- .../tests/factories.py | 2 +- .../tests/test_models.py | 18 +++++- .../{{cookiecutter.app_name}}/user/models.py | 45 ++++++--------- 4 files changed, 86 insertions(+), 36 deletions(-) diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/database.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/database.py index 15e2f8e..ec7f485 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/database.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/database.py @@ -2,10 +2,12 @@ """Database module, including the SQLAlchemy database object and DB-related utilities. """ - from sqlalchemy.orm import relationship -from .extensions import db +from sqlalchemy.types import TypeDecorator + +from .extensions import db, bcrypt +Column = db.Column relationship = relationship class CRUDMixin(object): @@ -49,9 +51,58 @@ class CRUDMixin(object): db.session.delete(self) return commit and db.session.commit() - +# From Mike Bayer's "atmcraft" example app +# https://speakerdeck.com/zzzeek/building-the-app def ReferenceCol(tablename, nullable=False, **kwargs): """Column that adds primary key foreign key reference.""" return db.Column( db.ForeignKey("{0}.id".format(tablename)), nullable=nullable, **kwargs) + + +class Password(str): + """Coerce a string to a bcrypt password. + + Rationale: for an easy string comparison, + so we can say ``some_password == 'hello123'`` + + .. seealso:: + + https://pypi.python.org/pypi/bcrypt/ + + """ + + def __new__(cls, value, crypt=True): + if value is None: + return None + if isinstance(value, unicode): + value = value.encode('utf-8') + if crypt: + value = bcrypt.generate_password_hash(value) + return str.__new__(cls, value) + + def __eq__(self, other): + if other and not isinstance(other, Password): + return bcrypt.check_password_hash(self, other) + return str.__eq__(self, other) + + def __ne__(self, other): + if other and not isinstance(other, Password): + return bcrypt.check_password_hash(self, other) + return not self.__eq__(other) + + +class BcryptType(TypeDecorator): + """Coerce strings to bcrypted Password objects for the database. + """ + impl = db.String(128) + + def process_bind_param(self, value, dialect): + return Password(value) + + def process_result_value(self, value, dialect): + # already crypted, so don't crypt again + return Password(value, crypt=False) + + def __repr__(self): + return "BcryptType()" diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/factories.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/factories.py index 4d6ed5d..96d87b8 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/factories.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/factories.py @@ -12,5 +12,5 @@ class UserFactory(SQLAlchemyModelFactory): username = Sequence(lambda n: "user{0}".format(n)) email = Sequence(lambda n: "user{0}@example.com".format(n)) - password = PostGenerationMethodCall("set_password", 'example') + password = 'example' active = True diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/test_models.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/test_models.py index 46ef22a..685baf8 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/test_models.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/tests/test_models.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import unittest +import datetime as dt from nose.tools import * # PEP8 asserts from {{ cookiecutter.app_name }}.database import db @@ -10,6 +11,17 @@ from .factories import UserFactory class TestUser(DbTestCase): + def test_created_at_defaults_to_utcnow(self): + user = User(username='foo', email='foo@bar.com') + user.save() + assert_true(user.created_at) + assert_true(isinstance(user.created_at, dt.datetime)) + + def test_password_is_nullable(self): + user = User(username='foo', email='foo@bar.com') + user.save() + assert_is(user.password, None) + def test_factory(self): user = UserFactory(password="myprecious") assert_true(user.username) @@ -17,13 +29,13 @@ class TestUser(DbTestCase): assert_true(user.created_at) assert_false(user.is_admin) assert_true(user.active) - assert_true(user.check_password("myprecious")) + assert_true(user.password == "myprecious") def test_check_password(self): user = User.create(username="foo", email="foo@bar.com", password="foobarbaz123") - assert_true(user.check_password('foobarbaz123')) - assert_false(user.check_password("barfoobaz")) + assert_true(user.password == 'foobarbaz123') + assert_false(user.password != "barfoobaz") def test_full_name(self): user = UserFactory(first_name="Foo", last_name="Bar") diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/user/models.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/user/models.py index 38c804b..663d917 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/user/models.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/user/models.py @@ -8,47 +8,34 @@ from {{cookiecutter.app_name}}.database import ( CRUDMixin, ReferenceCol, relationship, + Column, + BcryptType, ) from {{cookiecutter.app_name}}.extensions import bcrypt class Role(CRUDMixin, db.Model): __tablename__ = 'roles' - name = db.Column(db.String(80), unique=True, nullable=False) + name = Column(db.String(80), unique=True, nullable=False) user_id = ReferenceCol('users', nullable=True) user = relationship('User', backref='roles') class User(UserMixin, CRUDMixin, db.Model): __tablename__ = 'users' - username = db.Column(db.String(80), unique=True, nullable=False) - email = db.Column(db.String(80), unique=True, nullable=False) - password = db.Column(db.String, nullable=False) # The hashed password - created_at = db.Column(db.DateTime(), nullable=False) - first_name = db.Column(db.String(30), nullable=True) - last_name = db.Column(db.String(30), nullable=True) - active = db.Column(db.Boolean()) - is_admin = db.Column(db.Boolean()) - - - def __init__(self, username=None, email=None, password=None, - first_name=None, last_name=None, - active=False, is_admin=False): - self.username = username - self.email = email - if password: - self.set_password(password) - self.active = active - self.is_admin = is_admin - self.created_at = dt.datetime.utcnow() - self.first_name = first_name - self.last_name = last_name - - def set_password(self, password): - self.password = bcrypt.generate_password_hash(password) - - def check_password(self, password): - return bcrypt.check_password_hash(self.password, password) + username = Column(db.String(80), unique=True, nullable=False) + email = Column(db.String(80), unique=True, nullable=False) + password = Column(BcryptType, nullable=True) + created_at = Column(db.DateTime, nullable=False) + first_name = Column(db.String(30), nullable=True) + last_name = Column(db.String(30), nullable=True) + active = Column(db.Boolean(), default=False) + is_admin = Column(db.Boolean(), default=False) + + def __init__(self, username, email, **kwargs): + db.Model.__init__(self, username=username, email=email, **kwargs) + if not self.created_at: + self.created_at = dt.datetime.utcnow() @property def full_name(self):