From 18fbe65523fe44bf95cca6ae72dc9aa52e9e933e Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 29 Sep 2013 16:48:35 -0700 Subject: [PATCH 01/59] Initial commit --- hello.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 hello.py diff --git a/hello.py b/hello.py new file mode 100644 index 000000000..e69de29bb From 8466609343f30b031ebf9380a33fb34be4e9110f Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Fri, 27 Dec 2013 01:35:04 -0800 Subject: [PATCH 02/59] Chapter 1: initial version (1a) --- .gitignore | 42 ++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++++++++ README.md | 7 +++++++ 3 files changed, 70 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..37ce1aa50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# SQLite databases +*.sqlite + +# Virtual environment +venv diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..2e7905471 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Miguel Grinberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 000000000..2088fa41d --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Flasky +====== + +This repository contains the source code examples for my O'Reilly book [Flask Web Development](http://www.flaskbook.com). + +The commits and tags in this repository were carefully created to match the sequence in which concepts are presented in the book. Please read the section titled "How to Work with the Example Code" in the book's preface for instructions. + From 52a780d6542e37f0e7b6a919bf3d7861338e588c Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 22:58:48 -0800 Subject: [PATCH 03/59] Chapter 2: A complete application (2a) --- hello.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/hello.py b/hello.py index e69de29bb..062cbc298 100644 --- a/hello.py +++ b/hello.py @@ -0,0 +1,11 @@ +from flask import Flask +app = Flask(__name__) + + +@app.route('/') +def index(): + return '

Hello World!

' + + +if __name__ == '__main__': + app.run(debug=True) From 1970f982b6bf1baabb13bd802969dc5e3ffa8030 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 23:04:50 -0800 Subject: [PATCH 04/59] Chapter 2: Dynamic routes (2b) --- hello.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hello.py b/hello.py index 062cbc298..2bf2aedcd 100644 --- a/hello.py +++ b/hello.py @@ -7,5 +7,10 @@ def index(): return '

Hello World!

' +@app.route('/user/') +def user(name): + return '

Hello, %s!

' % name + + if __name__ == '__main__': app.run(debug=True) From 9405cbd5c52a7e6c8d423e545ffbaa08dc59849e Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Fri, 27 Dec 2013 00:37:59 -0800 Subject: [PATCH 05/59] Chapter 2: Command line options with Flask-Script (2c) --- hello.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hello.py b/hello.py index 2bf2aedcd..e58a0d9fd 100644 --- a/hello.py +++ b/hello.py @@ -1,6 +1,10 @@ from flask import Flask +from flask.ext.script import Manager + app = Flask(__name__) +manager = Manager(app) + @app.route('/') def index(): @@ -13,4 +17,4 @@ def user(name): if __name__ == '__main__': - app.run(debug=True) + manager.run() From e55fc2f5f6eba0c73bcdc3fa5c1608d9643a7340 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 23:01:48 -0800 Subject: [PATCH 06/59] Chapter 3: Templates (3a) --- hello.py | 6 +++--- templates/index.html | 1 + templates/user.html | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 templates/index.html create mode 100644 templates/user.html diff --git a/hello.py b/hello.py index e58a0d9fd..fccbfa2b1 100644 --- a/hello.py +++ b/hello.py @@ -1,4 +1,4 @@ -from flask import Flask +from flask import Flask, render_template from flask.ext.script import Manager app = Flask(__name__) @@ -8,12 +8,12 @@ @app.route('/') def index(): - return '

Hello World!

' + return render_template('index.html') @app.route('/user/') def user(name): - return '

Hello, %s!

' % name + return render_template('user.html', name=name) if __name__ == '__main__': diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 000000000..de8b69b6e --- /dev/null +++ b/templates/index.html @@ -0,0 +1 @@ +

Hello World!

diff --git a/templates/user.html b/templates/user.html new file mode 100644 index 000000000..33fdc85ef --- /dev/null +++ b/templates/user.html @@ -0,0 +1 @@ +

Hello, {{ name }}!

From 2cbde43e0588a6ba03943f9634ae60099c535732 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 23:12:43 -0800 Subject: [PATCH 07/59] Chapter 3: Templates with Flask-Bootstrap (3b) --- hello.py | 2 ++ templates/user.html | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/hello.py b/hello.py index fccbfa2b1..ed60dfa4c 100644 --- a/hello.py +++ b/hello.py @@ -1,9 +1,11 @@ from flask import Flask, render_template from flask.ext.script import Manager +from flask.ext.bootstrap import Bootstrap app = Flask(__name__) manager = Manager(app) +bootstrap = Bootstrap(app) @app.route('/') diff --git a/templates/user.html b/templates/user.html index 33fdc85ef..09b792d50 100644 --- a/templates/user.html +++ b/templates/user.html @@ -1 +1,32 @@ -

Hello, {{ name }}!

+{% extends "bootstrap/base.html" %} + +{% block title %}Flasky{% endblock %} + +{% block navbar %} + +{% endblock %} + +{% block content %} +
+ +
+{% endblock %} From c2a213b4fb393dbb5d549eed0adc9f95d70fe199 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 23:31:24 -0800 Subject: [PATCH 08/59] Chapter 3: Custom error pages (3c) --- hello.py | 10 ++++++++++ templates/404.html | 9 +++++++++ templates/500.html | 9 +++++++++ templates/base.html | 30 ++++++++++++++++++++++++++++++ templates/index.html | 10 +++++++++- templates/user.html | 31 ++++--------------------------- 6 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 templates/404.html create mode 100644 templates/500.html create mode 100644 templates/base.html diff --git a/hello.py b/hello.py index ed60dfa4c..a1a4a746e 100644 --- a/hello.py +++ b/hello.py @@ -8,6 +8,16 @@ bootstrap = Bootstrap(app) +@app.errorhandler(404) +def page_not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def internal_server_error(e): + return render_template('500.html'), 500 + + @app.route('/') def index(): return render_template('index.html') diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 000000000..819445079 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Page Not Found{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 000000000..0bb4f2727 --- /dev/null +++ b/templates/500.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Internal Server Error{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 000000000..305d3a353 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,30 @@ +{% extends "bootstrap/base.html" %} + +{% block title %}Flasky{% endblock %} + +{% block navbar %} + +{% endblock %} + +{% block content %} +
+ {% block page_content %}{% endblock %} +
+{% endblock %} diff --git a/templates/index.html b/templates/index.html index de8b69b6e..c0dd07be2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1 +1,9 @@ -

Hello World!

+{% extends "base.html" %} + +{% block title %}Flasky{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/templates/user.html b/templates/user.html index 09b792d50..ac4befc3a 100644 --- a/templates/user.html +++ b/templates/user.html @@ -1,32 +1,9 @@ -{% extends "bootstrap/base.html" %} +{% extends "base.html" %} {% block title %}Flasky{% endblock %} -{% block navbar %} - -{% endblock %} - -{% block content %} -
- +{% block page_content %} + {% endblock %} From c36b3aa18164ae1d201c9fe4f0c0f7e6d31dbbf5 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 26 Dec 2013 23:37:57 -0800 Subject: [PATCH 09/59] Chapter 3: Static files (3d) --- static/favicon.ico | Bin 0 -> 1150 bytes templates/base.html | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 static/favicon.ico diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8f674630aa3f565a08c157d4bf0919f5019269b4 GIT binary patch literal 1150 zcmbVMO{=L<6n@nmxdW%G?m(%_#4#{2QxYkK8GeD0pCB^^WFS%oNXo##KuP&1DT+iW z112O2r5Gp!`FQTT_i4MO;~d@fw%2<1+UwbCJ!|ha#|ilF?TzF0AI|lI<9>4-_s$RA z#d|*dyx};0?bpwbLFc@}Cvv@Bak*S@yWKun{pTWakMf9Fuh$rl$LMrAsMqW0^?KNB zHjgkmXV}?nhFB~Ho6QD^L;|&14XsuSkw^rwSPYd)1)WX@kH>T0htx|X9*@&FLQSL5 zU@#aUlgZFA91h|2dQmQyLF$@Lrxe3@q|<3Al}gBDGU)Ysbh};T^LZ?nOBzSi`FzId zbh^(Owp1!XDwV=&wbHr|hXZQ08n)Xljb%M0mhfbjEbjMvC=?2qOePqOM&xoi^!t6R zR;xRf{yb5^U=Tu~5P?7dl}d$jt5&NoVUNcn3+|_&&e3Re&&|%{Notx;E|()5 z4x`y@(w>t35>72eR2L7K;Un#UkzR zD|n~V3A^2nXf%psGKof`LHnObB(U4<=oVjbm`o-h{K>uX`~CF%3xxvO?e@3aS$(Ng lir#a#+f8xA|5Z+gW46_5AruPTF$Bx*C5w4x**oyR_Ad#kb)o + +{% endblock %} + {% block navbar %}
diff --git a/templates/index.html b/app/templates/index.html similarity index 100% rename from templates/index.html rename to app/templates/index.html diff --git a/templates/mail/new_user.html b/app/templates/mail/new_user.html similarity index 100% rename from templates/mail/new_user.html rename to app/templates/mail/new_user.html diff --git a/templates/mail/new_user.txt b/app/templates/mail/new_user.txt similarity index 100% rename from templates/mail/new_user.txt rename to app/templates/mail/new_user.txt diff --git a/config.py b/config.py new file mode 100644 index 000000000..f1771705b --- /dev/null +++ b/config.py @@ -0,0 +1,45 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string' + SQLALCHEMY_COMMIT_ON_TEARDOWN = True + MAIL_SERVER = 'smtp.googlemail.com' + MAIL_PORT = 587 + MAIL_USE_TLS = True + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]' + FLASKY_MAIL_SENDER = 'Flasky Admin ' + FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN') + + @staticmethod + def init_app(app): + pass + + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite') + + +class TestingConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') + + +class ProductionConfig(Config): + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'data.sqlite') + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + + 'default': DevelopmentConfig +} diff --git a/hello.py b/hello.py deleted file mode 100644 index dc3aac219..000000000 --- a/hello.py +++ /dev/null @@ -1,115 +0,0 @@ -import os -from threading import Thread -from flask import Flask, render_template, session, redirect, url_for -from flask.ext.script import Manager, Shell -from flask.ext.bootstrap import Bootstrap -from flask.ext.moment import Moment -from flask.ext.wtf import Form -from wtforms import StringField, SubmitField -from wtforms.validators import Required -from flask.ext.sqlalchemy import SQLAlchemy -from flask.ext.migrate import Migrate, MigrateCommand -from flask.ext.mail import Mail, Message - -basedir = os.path.abspath(os.path.dirname(__file__)) - -app = Flask(__name__) -app.config['SECRET_KEY'] = 'hard to guess string' -app.config['SQLALCHEMY_DATABASE_URI'] =\ - 'sqlite:///' + os.path.join(basedir, 'data.sqlite') -app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True -app.config['MAIL_SERVER'] = 'smtp.googlemail.com' -app.config['MAIL_PORT'] = 587 -app.config['MAIL_USE_TLS'] = True -app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME') -app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD') -app.config['FLASKY_MAIL_SUBJECT_PREFIX'] = '[Flasky]' -app.config['FLASKY_MAIL_SENDER'] = 'Flasky Admin ' -app.config['FLASKY_ADMIN'] = os.environ.get('FLASKY_ADMIN') - -manager = Manager(app) -bootstrap = Bootstrap(app) -moment = Moment(app) -db = SQLAlchemy(app) -migrate = Migrate(app, db) -mail = Mail(app) - - -class Role(db.Model): - __tablename__ = 'roles' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), unique=True) - users = db.relationship('User', backref='role', lazy='dynamic') - - def __repr__(self): - return '' % self.name - - -class User(db.Model): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), unique=True, index=True) - role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) - - def __repr__(self): - return '' % self.username - - -def send_async_email(app, msg): - with app.app_context(): - mail.send(msg) - - -def send_email(to, subject, template, **kwargs): - msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject, - sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to]) - msg.body = render_template(template + '.txt', **kwargs) - msg.html = render_template(template + '.html', **kwargs) - thr = Thread(target=send_async_email, args=[app, msg]) - thr.start() - return thr - - -class NameForm(Form): - name = StringField('What is your name?', validators=[Required()]) - submit = SubmitField('Submit') - - -def make_shell_context(): - return dict(app=app, db=db, User=User, Role=Role) -manager.add_command("shell", Shell(make_context=make_shell_context)) -manager.add_command('db', MigrateCommand) - - -@app.errorhandler(404) -def page_not_found(e): - return render_template('404.html'), 404 - - -@app.errorhandler(500) -def internal_server_error(e): - return render_template('500.html'), 500 - - -@app.route('/', methods=['GET', 'POST']) -def index(): - form = NameForm() - if form.validate_on_submit(): - user = User.query.filter_by(username=form.name.data).first() - if user is None: - user = User(username=form.name.data) - db.session.add(user) - session['known'] = False - if app.config['FLASKY_ADMIN']: - send_email(app.config['FLASKY_ADMIN'], 'New User', - 'mail/new_user', user=user) - else: - session['known'] = True - session['name'] = form.name.data - return redirect(url_for('index')) - return render_template('index.html', form=form, name=session.get('name'), - known=session.get('known', False)) - - -if __name__ == '__main__': - manager.run() diff --git a/manage.py b/manage.py new file mode 100755 index 000000000..49a55ade9 --- /dev/null +++ b/manage.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +import os +from app import create_app, db +from app.models import User, Role +from flask.ext.script import Manager, Shell +from flask.ext.migrate import Migrate, MigrateCommand + +app = create_app(os.getenv('FLASK_CONFIG') or 'default') +manager = Manager(app) +migrate = Migrate(app, db) + + +def make_shell_context(): + return dict(app=app, db=db, User=User, Role=Role) +manager.add_command("shell", Shell(make_context=make_shell_context)) +manager.add_command('db', MigrateCommand) + + +@manager.command +def test(): + """Run the unit tests.""" + import unittest + tests = unittest.TestLoader().discover('tests') + unittest.TextTestRunner(verbosity=2).run(tests) + + +if __name__ == '__main__': + manager.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..cfcf89084 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +Flask==0.10.1 +Flask-Bootstrap==3.0.3.1 +Flask-Mail==0.9.0 +Flask-Migrate==1.1.0 +Flask-Moment==0.2.1 +Flask-SQLAlchemy==1.0 +Flask-Script==0.6.6 +Flask-WTF==0.9.4 +Jinja2==2.7.1 +Mako==0.9.1 +MarkupSafe==0.18 +SQLAlchemy==0.9.9 +WTForms==1.0.5 +Werkzeug==0.10.4 +alembic==0.6.2 +blinker==1.3 +itsdangerous==0.23 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 000000000..0fdf4983b --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,22 @@ +import unittest +from flask import current_app +from app import create_app, db + + +class BasicsTestCase(unittest.TestCase): + def setUp(self): + self.app = create_app('testing') + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_app_exists(self): + self.assertFalse(current_app is None) + + def test_app_is_testing(self): + self.assertTrue(current_app.config['TESTING']) From 9bc4c7c5035e1f76241cdbef187a38252bd92847 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sat, 28 Dec 2013 01:05:18 -0800 Subject: [PATCH 21/59] Chapter 8: Password hashing with Werkzeug (8a) --- app/models.py | 13 +++++++++++++ tests/test_user_model.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/test_user_model.py diff --git a/app/models.py b/app/models.py index 5c885d668..c938f0aff 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,4 @@ +from werkzeug.security import generate_password_hash, check_password_hash from . import db @@ -16,6 +17,18 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) + password_hash = db.Column(db.String(128)) + + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = generate_password_hash(password) + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) def __repr__(self): return '' % self.username diff --git a/tests/test_user_model.py b/tests/test_user_model.py new file mode 100644 index 000000000..b705a3bcf --- /dev/null +++ b/tests/test_user_model.py @@ -0,0 +1,35 @@ +import unittest +from app import create_app, db +from app.models import User + + +class UserModelTestCase(unittest.TestCase): + def setUp(self): + self.app = create_app('testing') + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_password_setter(self): + u = User(password='cat') + self.assertTrue(u.password_hash is not None) + + def test_no_password_getter(self): + u = User(password='cat') + with self.assertRaises(AttributeError): + u.password + + def test_password_verification(self): + u = User(password='cat') + self.assertTrue(u.verify_password('cat')) + self.assertFalse(u.verify_password('dog')) + + def test_password_salts_are_random(self): + u = User(password='cat') + u2 = User(password='cat') + self.assertTrue(u.password_hash != u2.password_hash) From 60320bdf54a395986e9433c89b47d6dc1849b242 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sat, 28 Dec 2013 19:36:00 -0800 Subject: [PATCH 22/59] Chapter 8: Authentication blueprint (8b) --- app/__init__.py | 3 +++ app/auth/__init__.py | 5 +++++ app/auth/views.py | 7 +++++++ app/templates/auth/login.html | 9 +++++++++ 4 files changed, 24 insertions(+) create mode 100644 app/auth/__init__.py create mode 100644 app/auth/views.py create mode 100644 app/templates/auth/login.html diff --git a/app/__init__.py b/app/__init__.py index 97f296d09..dde92c91f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -24,5 +24,8 @@ def create_app(config_name): from .main import main as main_blueprint app.register_blueprint(main_blueprint) + from .auth import auth as auth_blueprint + app.register_blueprint(auth_blueprint, url_prefix='/auth') + return app diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 000000000..e54b37dc2 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +auth = Blueprint('auth', __name__) + +from . import views diff --git a/app/auth/views.py b/app/auth/views.py new file mode 100644 index 000000000..50109e0a4 --- /dev/null +++ b/app/auth/views.py @@ -0,0 +1,7 @@ +from flask import render_template +from . import auth + + +@auth.route('/login') +def login(): + return render_template('auth/login.html') diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 000000000..237fbf23b --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Login{% endblock %} + +{% block page_content %} + +{% endblock %} \ No newline at end of file From d35514174293c2f074913891eaf64a3a07d23a14 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 30 Jan 2014 10:07:13 -0800 Subject: [PATCH 23/59] Chapter 8: Login and logout with Flask-Login (8c) --- app/__init__.py | 7 ++++- app/auth/forms.py | 11 +++++++ app/auth/views.py | 24 +++++++++++++-- app/main/views.py | 26 ++-------------- app/models.py | 11 +++++-- app/templates/auth/login.html | 6 +++- app/templates/base.html | 7 +++++ app/templates/index.html | 9 +----- .../versions/456a945560f6_login_support.py | 30 +++++++++++++++++++ requirements.txt | 1 + 10 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 app/auth/forms.py create mode 100644 migrations/versions/456a945560f6_login_support.py diff --git a/app/__init__.py b/app/__init__.py index dde92c91f..a122c88cd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,7 @@ from flask.ext.mail import Mail from flask.ext.moment import Moment from flask.ext.sqlalchemy import SQLAlchemy +from flask.ext.login import LoginManager from config import config bootstrap = Bootstrap() @@ -10,6 +11,10 @@ moment = Moment() db = SQLAlchemy() +login_manager = LoginManager() +login_manager.session_protection = 'strong' +login_manager.login_view = 'auth.login' + def create_app(config_name): app = Flask(__name__) @@ -20,6 +25,7 @@ def create_app(config_name): mail.init_app(app) moment.init_app(app) db.init_app(app) + login_manager.init_app(app) from .main import main as main_blueprint app.register_blueprint(main_blueprint) @@ -28,4 +34,3 @@ def create_app(config_name): app.register_blueprint(auth_blueprint, url_prefix='/auth') return app - diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 000000000..e14b25d49 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,11 @@ +from flask.ext.wtf import Form +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import Required, Length, Email + + +class LoginForm(Form): + email = StringField('Email', validators=[Required(), Length(1, 64), + Email()]) + password = PasswordField('Password', validators=[Required()]) + remember_me = BooleanField('Keep me logged in') + submit = SubmitField('Log In') diff --git a/app/auth/views.py b/app/auth/views.py index 50109e0a4..c1be1f904 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,7 +1,25 @@ -from flask import render_template +from flask import render_template, redirect, request, url_for, flash +from flask.ext.login import login_user, logout_user, login_required from . import auth +from ..models import User +from .forms import LoginForm -@auth.route('/login') +@auth.route('/login', methods=['GET', 'POST']) def login(): - return render_template('auth/login.html') + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user is not None and user.verify_password(form.password.data): + login_user(user, form.remember_me.data) + return redirect(request.args.get('next') or url_for('main.index')) + flash('Invalid username or password.') + return render_template('auth/login.html', form=form) + + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.') + return redirect(url_for('main.index')) diff --git a/app/main/views.py b/app/main/views.py index 071e1709d..c8520dea6 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -1,27 +1,7 @@ -from flask import render_template, session, redirect, url_for, current_app -from .. import db -from ..models import User -from ..email import send_email +from flask import render_template from . import main -from .forms import NameForm -@main.route('/', methods=['GET', 'POST']) +@main.route('/') def index(): - form = NameForm() - if form.validate_on_submit(): - user = User.query.filter_by(username=form.name.data).first() - if user is None: - user = User(username=form.name.data) - db.session.add(user) - session['known'] = False - if current_app.config['FLASKY_ADMIN']: - send_email(current_app.config['FLASKY_ADMIN'], 'New User', - 'mail/new_user', user=user) - else: - session['known'] = True - session['name'] = form.name.data - return redirect(url_for('.index')) - return render_template('index.html', - form=form, name=session.get('name'), - known=session.get('known', False)) + return render_template('index.html') diff --git a/app/models.py b/app/models.py index c938f0aff..3de19dd5a 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from werkzeug.security import generate_password_hash, check_password_hash -from . import db +from flask.ext.login import UserMixin +from . import db, login_manager class Role(db.Model): @@ -12,9 +13,10 @@ def __repr__(self): return '' % self.name -class User(db.Model): +class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) password_hash = db.Column(db.String(128)) @@ -32,3 +34,8 @@ def verify_password(self, password): def __repr__(self): return '' % self.username + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 237fbf23b..476cff57c 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky - Login{% endblock %} @@ -6,4 +7,7 @@ -{% endblock %} \ No newline at end of file +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 17b38fcaf..bc2c94fe2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,6 +24,13 @@ + diff --git a/app/templates/index.html b/app/templates/index.html index b5657a7f5..90cebeb7a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,16 +1,9 @@ {% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky{% endblock %} {% block page_content %} -{{ wtf.quick_form(form) }} {% endblock %} diff --git a/migrations/versions/456a945560f6_login_support.py b/migrations/versions/456a945560f6_login_support.py new file mode 100644 index 000000000..bb75e5097 --- /dev/null +++ b/migrations/versions/456a945560f6_login_support.py @@ -0,0 +1,30 @@ +"""login support + +Revision ID: 456a945560f6 +Revises: 38c4e85512a9 +Create Date: 2013-12-29 00:18:35.795259 + +""" + +# revision identifiers, used by Alembic. +revision = '456a945560f6' +down_revision = '38c4e85512a9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('email', sa.String(length=64), nullable=True)) + op.add_column('users', sa.Column('password_hash', sa.String(length=128), nullable=True)) + op.create_index('ix_users_email', 'users', ['email'], unique=True) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_users_email', 'users') + op.drop_column('users', 'password_hash') + op.drop_column('users', 'email') + ### end Alembic commands ### \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cfcf89084..e1e565bed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Flask==0.10.1 Flask-Bootstrap==3.0.3.1 +Flask-Login==0.3.1 Flask-Mail==0.9.0 Flask-Migrate==1.1.0 Flask-Moment==0.2.1 From 4d16904c727e6379117883bd30156a1d442aa9f0 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Thu, 30 Jan 2014 10:18:40 -0800 Subject: [PATCH 24/59] Chapter 8: User registration (8d) --- app/auth/forms.py | 25 ++++++++++++++++++++++++- app/auth/views.py | 20 ++++++++++++++++++-- app/templates/auth/login.html | 2 ++ app/templates/auth/register.html | 13 +++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 app/templates/auth/register.html diff --git a/app/auth/forms.py b/app/auth/forms.py index e14b25d49..7db80bccc 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -1,6 +1,8 @@ from flask.ext.wtf import Form from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import Required, Length, Email +from wtforms.validators import Required, Length, Email, Regexp, EqualTo +from wtforms import ValidationError +from ..models import User class LoginForm(Form): @@ -9,3 +11,24 @@ class LoginForm(Form): password = PasswordField('Password', validators=[Required()]) remember_me = BooleanField('Keep me logged in') submit = SubmitField('Log In') + + +class RegistrationForm(Form): + email = StringField('Email', validators=[Required(), Length(1, 64), + Email()]) + username = StringField('Username', validators=[ + Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, + 'Usernames must have only letters, ' + 'numbers, dots or underscores')]) + password = PasswordField('Password', validators=[ + Required(), EqualTo('password2', message='Passwords must match.')]) + password2 = PasswordField('Confirm password', validators=[Required()]) + submit = SubmitField('Register') + + def validate_email(self, field): + if User.query.filter_by(email=field.data).first(): + raise ValidationError('Email already registered.') + + def validate_username(self, field): + if User.query.filter_by(username=field.data).first(): + raise ValidationError('Username already in use.') diff --git a/app/auth/views.py b/app/auth/views.py index c1be1f904..75934fa50 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,8 +1,11 @@ from flask import render_template, redirect, request, url_for, flash -from flask.ext.login import login_user, logout_user, login_required +from flask.ext.login import login_user, logout_user, login_required, \ + current_user from . import auth +from .. import db from ..models import User -from .forms import LoginForm +from ..email import send_email +from .forms import LoginForm, RegistrationForm @auth.route('/login', methods=['GET', 'POST']) @@ -23,3 +26,16 @@ def logout(): logout_user() flash('You have been logged out.') return redirect(url_for('main.index')) + + +@auth.route('/register', methods=['GET', 'POST']) +def register(): + form = RegistrationForm() + if form.validate_on_submit(): + user = User(email=form.email.data, + username=form.username.data, + password=form.password.data) + db.session.add(user) + flash('You can now login.') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', form=form) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 476cff57c..1e14c7f5a 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -9,5 +9,7 @@

Login

{{ wtf.quick_form(form) }} +
+

New user? Click here to register.

{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 000000000..eb14df9e0 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %}Flasky - Register{% endblock %} + +{% block page_content %} + +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} From 2bf7718df37f076e80a01ea67772fe917d09bdc3 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 29 Dec 2013 00:10:10 -0800 Subject: [PATCH 25/59] Chapter 8: Account confirmation (8e) --- app/auth/views.py | 44 ++++++++++++++++++- app/models.py | 19 ++++++++ app/templates/auth/email/confirm.html | 8 ++++ app/templates/auth/email/confirm.txt | 13 ++++++ app/templates/auth/unconfirmed.html | 20 +++++++++ .../190163627111_account_confirmation.py | 26 +++++++++++ .../versions/456a945560f6_login_support.py | 2 +- tests/test_user_model.py | 25 +++++++++++ 8 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 app/templates/auth/email/confirm.html create mode 100644 app/templates/auth/email/confirm.txt create mode 100644 app/templates/auth/unconfirmed.html create mode 100644 migrations/versions/190163627111_account_confirmation.py diff --git a/app/auth/views.py b/app/auth/views.py index 75934fa50..f5eb0128e 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -8,6 +8,22 @@ from .forms import LoginForm, RegistrationForm +@auth.before_app_request +def before_request(): + if current_user.is_authenticated \ + and not current_user.confirmed \ + and request.endpoint[:5] != 'auth.' \ + and request.endpoint != 'static': + return redirect(url_for('auth.unconfirmed')) + + +@auth.route('/unconfirmed') +def unconfirmed(): + if current_user.is_anonymous or current_user.confirmed: + return redirect(url_for('main.index')) + return render_template('auth/unconfirmed.html') + + @auth.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() @@ -36,6 +52,32 @@ def register(): username=form.username.data, password=form.password.data) db.session.add(user) - flash('You can now login.') + db.session.commit() + token = user.generate_confirmation_token() + send_email(user.email, 'Confirm Your Account', + 'auth/email/confirm', user=user, token=token) + flash('A confirmation email has been sent to you by email.') return redirect(url_for('auth.login')) return render_template('auth/register.html', form=form) + + +@auth.route('/confirm/') +@login_required +def confirm(token): + if current_user.confirmed: + return redirect(url_for('main.index')) + if current_user.confirm(token): + flash('You have confirmed your account. Thanks!') + else: + flash('The confirmation link is invalid or has expired.') + return redirect(url_for('main.index')) + + +@auth.route('/confirm') +@login_required +def resend_confirmation(): + token = current_user.generate_confirmation_token() + send_email(current_user.email, 'Confirm Your Account', + 'auth/email/confirm', user=current_user, token=token) + flash('A new confirmation email has been sent to you by email.') + return redirect(url_for('main.index')) diff --git a/app/models.py b/app/models.py index 3de19dd5a..8487178fa 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,6 @@ from werkzeug.security import generate_password_hash, check_password_hash +from itsdangerous import TimedJSONWebSignatureSerializer as Serializer +from flask import current_app from flask.ext.login import UserMixin from . import db, login_manager @@ -20,6 +22,7 @@ class User(UserMixin, db.Model): username = db.Column(db.String(64), unique=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) password_hash = db.Column(db.String(128)) + confirmed = db.Column(db.Boolean, default=False) @property def password(self): @@ -32,6 +35,22 @@ def password(self, password): def verify_password(self, password): return check_password_hash(self.password_hash, password) + def generate_confirmation_token(self, expiration=3600): + s = Serializer(current_app.config['SECRET_KEY'], expiration) + return s.dumps({'confirm': self.id}) + + def confirm(self, token): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token) + except: + return False + if data.get('confirm') != self.id: + return False + self.confirmed = True + db.session.add(self) + return True + def __repr__(self): return '' % self.username diff --git a/app/templates/auth/email/confirm.html b/app/templates/auth/email/confirm.html new file mode 100644 index 000000000..e15e221bf --- /dev/null +++ b/app/templates/auth/email/confirm.html @@ -0,0 +1,8 @@ +

Dear {{ user.username }},

+

Welcome to Flasky!

+

To confirm your account please click here.

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('auth.confirm', token=token, _external=True) }}

+

Sincerely,

+

The Flasky Team

+

Note: replies to this email address are not monitored.

diff --git a/app/templates/auth/email/confirm.txt b/app/templates/auth/email/confirm.txt new file mode 100644 index 000000000..16da41df1 --- /dev/null +++ b/app/templates/auth/email/confirm.txt @@ -0,0 +1,13 @@ +Dear {{ user.username }}, + +Welcome to Flasky! + +To confirm your account please click on the following link: + +{{ url_for('auth.confirm', token=token, _external=True) }} + +Sincerely, + +The Flasky Team + +Note: replies to this email address are not monitored. diff --git a/app/templates/auth/unconfirmed.html b/app/templates/auth/unconfirmed.html new file mode 100644 index 000000000..cdf194f77 --- /dev/null +++ b/app/templates/auth/unconfirmed.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Flasky - Confirm your accont{% endblock %} + +{% block page_content %} + +{% endblock %} diff --git a/migrations/versions/190163627111_account_confirmation.py b/migrations/versions/190163627111_account_confirmation.py new file mode 100644 index 000000000..7b5457613 --- /dev/null +++ b/migrations/versions/190163627111_account_confirmation.py @@ -0,0 +1,26 @@ +"""account confirmation + +Revision ID: 190163627111 +Revises: 456a945560f6 +Create Date: 2013-12-29 02:58:45.577428 + +""" + +# revision identifiers, used by Alembic. +revision = '190163627111' +down_revision = '456a945560f6' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('confirmed', sa.Boolean(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'confirmed') + ### end Alembic commands ### diff --git a/migrations/versions/456a945560f6_login_support.py b/migrations/versions/456a945560f6_login_support.py index bb75e5097..03afc0670 100644 --- a/migrations/versions/456a945560f6_login_support.py +++ b/migrations/versions/456a945560f6_login_support.py @@ -27,4 +27,4 @@ def downgrade(): op.drop_index('ix_users_email', 'users') op.drop_column('users', 'password_hash') op.drop_column('users', 'email') - ### end Alembic commands ### \ No newline at end of file + ### end Alembic commands ### diff --git a/tests/test_user_model.py b/tests/test_user_model.py index b705a3bcf..4c8765774 100644 --- a/tests/test_user_model.py +++ b/tests/test_user_model.py @@ -1,4 +1,5 @@ import unittest +import time from app import create_app, db from app.models import User @@ -33,3 +34,27 @@ def test_password_salts_are_random(self): u = User(password='cat') u2 = User(password='cat') self.assertTrue(u.password_hash != u2.password_hash) + + def test_valid_confirmation_token(self): + u = User(password='cat') + db.session.add(u) + db.session.commit() + token = u.generate_confirmation_token() + self.assertTrue(u.confirm(token)) + + def test_invalid_confirmation_token(self): + u1 = User(password='cat') + u2 = User(password='dog') + db.session.add(u1) + db.session.add(u2) + db.session.commit() + token = u1.generate_confirmation_token() + self.assertFalse(u2.confirm(token)) + + def test_expired_confirmation_token(self): + u = User(password='cat') + db.session.add(u) + db.session.commit() + token = u.generate_confirmation_token(1) + time.sleep(2) + self.assertFalse(u.confirm(token)) From 09143fae491219d66e55f63a9896fd78b01826e1 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Sun, 29 Dec 2013 12:20:24 -0800 Subject: [PATCH 26/59] Chapter 8: Password updates (8f) --- app/auth/forms.py | 8 ++++++++ app/auth/views.py | 17 ++++++++++++++++- app/templates/auth/change_password.html | 13 +++++++++++++ app/templates/base.html | 8 +++++++- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 app/templates/auth/change_password.html diff --git a/app/auth/forms.py b/app/auth/forms.py index 7db80bccc..dbf125d7a 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -32,3 +32,11 @@ def validate_email(self, field): def validate_username(self, field): if User.query.filter_by(username=field.data).first(): raise ValidationError('Username already in use.') + + +class ChangePasswordForm(Form): + old_password = PasswordField('Old password', validators=[Required()]) + password = PasswordField('New password', validators=[ + Required(), EqualTo('password2', message='Passwords must match')]) + password2 = PasswordField('Confirm new password', validators=[Required()]) + submit = SubmitField('Update Password') diff --git a/app/auth/views.py b/app/auth/views.py index f5eb0128e..758f126a6 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -5,7 +5,7 @@ from .. import db from ..models import User from ..email import send_email -from .forms import LoginForm, RegistrationForm +from .forms import LoginForm, RegistrationForm, ChangePasswordForm @auth.before_app_request @@ -81,3 +81,18 @@ def resend_confirmation(): 'auth/email/confirm', user=current_user, token=token) flash('A new confirmation email has been sent to you by email.') return redirect(url_for('main.index')) + + +@auth.route('/change-password', methods=['GET', 'POST']) +@login_required +def change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + if current_user.verify_password(form.old_password.data): + current_user.password = form.password.data + db.session.add(current_user) + flash('Your password has been updated.') + return redirect(url_for('main.index')) + else: + flash('Invalid password.') + return render_template("auth/change_password.html", form=form) diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 000000000..374d86206 --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %}Flasky - Change Password{% endblock %} + +{% block page_content %} + +
+ {{ wtf.quick_form(form) }} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index bc2c94fe2..cd96c3338 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,7 +26,13 @@