From c786cd15818c6e2d3895aeb4f13595714d3fce06 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:15 -0700
Subject: [PATCH 01/45] Chapter 7: Large file structure (7a)
---
 app/__init__.py                               |  28 +++++
 app/email.py                                  |  20 ++++
 app/main/__init__.py                          |   5 +
 app/main/errors.py                            |  12 ++
 app/main/forms.py                             |   8 ++
 app/main/views.py                             |  28 +++++
 app/models.py                                 |  21 ++++
 {static => app/static}/favicon.ico            | Bin
 {templates => app/templates}/404.html         |   0
 {templates => app/templates}/500.html         |   0
 {templates => app/templates}/base.html        |   4 +-
 {templates => app/templates}/index.html       |   0
 .../templates}/mail/new_user.html             |   0
 .../templates}/mail/new_user.txt              |   0
 config.py                                     |  46 ++++++++
 flasky.py                                     |  25 ++++
 hello.py                                      | 109 ------------------
 requirements.txt                              |  22 ++++
 tests/__init__.py                             |   0
 tests/test_basics.py                          |  22 ++++
 20 files changed, 239 insertions(+), 111 deletions(-)
 create mode 100644 app/__init__.py
 create mode 100644 app/email.py
 create mode 100644 app/main/__init__.py
 create mode 100644 app/main/errors.py
 create mode 100644 app/main/forms.py
 create mode 100644 app/main/views.py
 create mode 100644 app/models.py
 rename {static => app/static}/favicon.ico (100%)
 rename {templates => app/templates}/404.html (100%)
 rename {templates => app/templates}/500.html (100%)
 rename {templates => app/templates}/base.html (89%)
 rename {templates => app/templates}/index.html (100%)
 rename {templates => app/templates}/mail/new_user.html (100%)
 rename {templates => app/templates}/mail/new_user.txt (100%)
 create mode 100644 config.py
 create mode 100644 flasky.py
 delete mode 100644 hello.py
 create mode 100644 requirements.txt
 create mode 100644 tests/__init__.py
 create mode 100644 tests/test_basics.py
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 000000000..4ca4c4145
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,28 @@
+from flask import Flask
+from flask_bootstrap import Bootstrap
+from flask_mail import Mail
+from flask_moment import Moment
+from flask_sqlalchemy import SQLAlchemy
+from config import config
+
+bootstrap = Bootstrap()
+mail = Mail()
+moment = Moment()
+db = SQLAlchemy()
+
+
+def create_app(config_name):
+    app = Flask(__name__)
+    app.config.from_object(config[config_name])
+    config[config_name].init_app(app)
+
+    bootstrap.init_app(app)
+    mail.init_app(app)
+    moment.init_app(app)
+    db.init_app(app)
+
+    from .main import main as main_blueprint
+    app.register_blueprint(main_blueprint)
+
+    return app
+
diff --git a/app/email.py b/app/email.py
new file mode 100644
index 000000000..0f6ac520b
--- /dev/null
+++ b/app/email.py
@@ -0,0 +1,20 @@
+from threading import Thread
+from flask import current_app, render_template
+from flask_mail import Message
+from . import mail
+
+
+def send_async_email(app, msg):
+    with app.app_context():
+        mail.send(msg)
+
+
+def send_email(to, subject, template, **kwargs):
+    app = current_app._get_current_object()
+    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
diff --git a/app/main/__init__.py b/app/main/__init__.py
new file mode 100644
index 000000000..90380f84d
--- /dev/null
+++ b/app/main/__init__.py
@@ -0,0 +1,5 @@
+from flask import Blueprint
+
+main = Blueprint('main', __name__)
+
+from . import views, errors
diff --git a/app/main/errors.py b/app/main/errors.py
new file mode 100644
index 000000000..7c76c776d
--- /dev/null
+++ b/app/main/errors.py
@@ -0,0 +1,12 @@
+from flask import render_template
+from . import main
+
+
+@main.app_errorhandler(404)
+def page_not_found(e):
+    return render_template('404.html'), 404
+
+
+@main.app_errorhandler(500)
+def internal_server_error(e):
+    return render_template('500.html'), 500
diff --git a/app/main/forms.py b/app/main/forms.py
new file mode 100644
index 000000000..2ca927755
--- /dev/null
+++ b/app/main/forms.py
@@ -0,0 +1,8 @@
+from flask_wtf import FlaskForm
+from wtforms import StringField, SubmitField
+from wtforms.validators import DataRequired
+
+
+class NameForm(FlaskForm):
+    name = StringField('What is your name?', validators=[DataRequired()])
+    submit = SubmitField('Submit')
diff --git a/app/main/views.py b/app/main/views.py
new file mode 100644
index 000000000..2440eb273
--- /dev/null
+++ b/app/main/views.py
@@ -0,0 +1,28 @@
+from flask import render_template, session, redirect, url_for, current_app
+from .. import db
+from ..models import User
+from ..email import send_email
+from . import main
+from .forms import NameForm
+
+
+@main.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)
+            db.session.commit()
+            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))
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 000000000..5c885d668
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,21 @@
+from . import db
+
+
+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
diff --git a/static/favicon.ico b/app/static/favicon.ico
similarity index 100%
rename from static/favicon.ico
rename to app/static/favicon.ico
diff --git a/templates/404.html b/app/templates/404.html
similarity index 100%
rename from templates/404.html
rename to app/templates/404.html
diff --git a/templates/500.html b/app/templates/500.html
similarity index 100%
rename from templates/500.html
rename to app/templates/500.html
diff --git a/templates/base.html b/app/templates/base.html
similarity index 89%
rename from templates/base.html
rename to app/templates/base.html
index 92ef01d69..17b38fcaf 100644
--- a/templates/base.html
+++ b/app/templates/base.html
@@ -18,11 +18,11 @@
                 Flasky 
+            Flasky 
         
         
     
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..235923839
--- /dev/null
+++ b/config.py
@@ -0,0 +1,46 @@
+import os
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+
+class Config:
+    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
+    MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
+    MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
+    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \
+        ['true', 'on', '1']
+    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')
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+
+    @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://'
+
+
+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/flasky.py b/flasky.py
new file mode 100644
index 000000000..8a4d1adca
--- /dev/null
+++ b/flasky.py
@@ -0,0 +1,25 @@
+import os
+import click
+from flask_migrate import Migrate
+from app import create_app, db
+from app.models import User, Role
+
+app = create_app(os.getenv('FLASK_CONFIG') or 'default')
+migrate = Migrate(app, db)
+
+
+@app.shell_context_processor
+def make_shell_context():
+    return dict(db=db, User=User, Role=Role)
+
+
+@app.cli.command()
+@click.argument('test_names', nargs=-1)
+def test(test_names):
+    """Run the unit tests."""
+    import unittest
+    if test_names:
+        tests = unittest.TestLoader().loadTestsFromNames(test_names)
+    else:
+        tests = unittest.TestLoader().discover('tests')
+    unittest.TextTestRunner(verbosity=2).run(tests)
diff --git a/hello.py b/hello.py
deleted file mode 100644
index 7e0e58f3a..000000000
--- a/hello.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import os
-from threading import Thread
-from flask import Flask, render_template, session, redirect, url_for
-from flask_bootstrap import Bootstrap
-from flask_moment import Moment
-from flask_wtf import FlaskForm
-from wtforms import StringField, SubmitField
-from wtforms.validators import DataRequired
-from flask_sqlalchemy import SQLAlchemy
-from flask_migrate import Migrate
-from flask_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_TRACK_MODIFICATIONS'] = False
-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')
-
-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(FlaskForm):
-    name = StringField('What is your name?', validators=[DataRequired()])
-    submit = SubmitField('Submit')
-
-
-@app.shell_context_processor
-def make_shell_context():
-    return dict(db=db, User=User, Role=Role)
-
-
-@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)
-            db.session.commit()
-            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))
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 000000000..1434adb59
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,22 @@
+alembic==0.9.3
+blinker==1.4
+click==6.7
+dominate==2.3.1
+Flask==0.12.2
+Flask-Bootstrap==3.3.7.1
+Flask-Mail==0.9.1
+Flask-Migrate==2.0.4
+Flask-Moment==0.5.1
+Flask-SQLAlchemy==2.2
+Flask-WTF==0.14.2
+itsdangerous==0.24
+Jinja2==2.9.6
+Mako==1.0.7
+MarkupSafe==1.1.1
+python-dateutil==2.6.1
+python-editor==1.0.3
+six==1.10.0
+SQLAlchemy==1.1.11
+visitor==0.1.3
+Werkzeug==0.12.2
+WTForms==2.1
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 e88ac5a6e8d312ce4c5d0c96a5403a0fc22bf76b Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:16 -0700
Subject: [PATCH 02/45] 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 2a8423aa8138fec81367ad08e16c3a4e774f81eb Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:17 -0700
Subject: [PATCH 03/45] 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 4ca4c4145..968cc319b 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 f958fae21602e758ed8c4679f811b139005d76ce Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:18 -0700
Subject: [PATCH 04/45] Chapter 8: Login and logout with Flask-Login (8c)
---
 app/__init__.py                               |  6 +++-
 app/auth/forms.py                             | 11 +++++++
 app/auth/views.py                             | 27 +++++++++++++++--
 app/main/views.py                             | 27 ++---------------
 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, 96 insertions(+), 39 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 968cc319b..07718afaa 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -3,6 +3,7 @@
 from flask_mail import Mail
 from flask_moment import Moment
 from flask_sqlalchemy import SQLAlchemy
+from flask_login import LoginManager
 from config import config
 
 bootstrap = Bootstrap()
@@ -10,6 +11,9 @@
 moment = Moment()
 db = SQLAlchemy()
 
+login_manager = LoginManager()
+login_manager.login_view = 'auth.login'
+
 
 def create_app(config_name):
     app = Flask(__name__)
@@ -20,6 +24,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 +33,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..d50cf956b
--- /dev/null
+++ b/app/auth/forms.py
@@ -0,0 +1,11 @@
+from flask_wtf import FlaskForm
+from wtforms import StringField, PasswordField, BooleanField, SubmitField
+from wtforms.validators import DataRequired, Length, Email
+
+
+class LoginForm(FlaskForm):
+    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
+                                             Email()])
+    password = PasswordField('Password', validators=[DataRequired()])
+    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..395254359 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -1,7 +1,28 @@
-from flask import render_template
+from flask import render_template, redirect, request, url_for, flash
+from flask_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.lower()).first()
+        if user is not None and user.verify_password(form.password.data):
+            login_user(user, form.remember_me.data)
+            next = request.args.get('next')
+            if next is None or not next.startswith('/'):
+                next = url_for('main.index')
+            return redirect(next)
+        flash('Invalid email 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 2440eb273..c8520dea6 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,28 +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)
-            db.session.commit()
-            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..729819dbf 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_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 @@
             
+            
+                {% if current_user.is_authenticated %}
+                Log Out Log In  
         
     
 
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 1434adb59..b7c3da159 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ click==6.7
 dominate==2.3.1
 Flask==0.12.2
 Flask-Bootstrap==3.3.7.1
+Flask-Login==0.4.0
 Flask-Mail==0.9.1
 Flask-Migrate==2.0.4
 Flask-Moment==0.5.1
From 0169aea7745188671eb9c0fb71f3af48b9459dac Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:19 -0700
Subject: [PATCH 05/45] Chapter 8: User registration (8d)
---
 app/auth/forms.py                | 26 +++++++++++++++++++++++++-
 app/auth/views.py                | 21 +++++++++++++++++++--
 app/templates/auth/login.html    |  2 ++
 app/templates/auth/register.html | 13 +++++++++++++
 4 files changed, 59 insertions(+), 3 deletions(-)
 create mode 100644 app/templates/auth/register.html
diff --git a/app/auth/forms.py b/app/auth/forms.py
index d50cf956b..cfaf89e38 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -1,6 +1,8 @@
 from flask_wtf import FlaskForm
 from wtforms import StringField, PasswordField, BooleanField, SubmitField
-from wtforms.validators import DataRequired, Length, Email
+from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo
+from wtforms import ValidationError
+from ..models import User
 
 
 class LoginForm(FlaskForm):
@@ -9,3 +11,25 @@ class LoginForm(FlaskForm):
     password = PasswordField('Password', validators=[DataRequired()])
     remember_me = BooleanField('Keep me logged in')
     submit = SubmitField('Log In')
+
+
+class RegistrationForm(FlaskForm):
+    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
+                                             Email()])
+    username = StringField('Username', validators=[
+        DataRequired(), 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=[
+        DataRequired(), EqualTo('password2', message='Passwords must match.')])
+    password2 = PasswordField('Confirm password', validators=[DataRequired()])
+    submit = SubmitField('Register')
+
+    def validate_email(self, field):
+        if User.query.filter_by(email=field.data.lower()).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 395254359..ecd8ad976 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_login import login_user, logout_user, login_required
+from flask_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'])
@@ -26,3 +29,17 @@ 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.lower(),
+                    username=form.username.data,
+                    password=form.password.data)
+        db.session.add(user)
+        db.session.commit()
+        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 
 
 
 {% 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 edb7ecb880847f95c30c23de124595946d0f08ab Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:20 -0700
Subject: [PATCH 06/45] Chapter 8: Account confirmation (8e)
---
 app/auth/views.py                             | 45 ++++++++++++++++++-
 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, 156 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 ecd8ad976..9186daa4b 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -8,6 +8,23 @@
 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 \
+            and request.blueprint != '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()
@@ -40,6 +57,32 @@ def register():
                     password=form.password.data)
         db.session.add(user)
         db.session.commit()
-        flash('You can now login.')
+        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):
+        db.session.commit()
+        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 729819dbf..6c6030e83 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_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}).decode('utf-8')
+
+    def confirm(self, token):
+        s = Serializer(current_app.config['SECRET_KEY'])
+        try:
+            data = s.loads(token.encode('utf-8'))
+        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..75bf19a48
--- /dev/null
+++ b/app/templates/auth/unconfirmed.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Confirm your account{% 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 b7c47012532d24d45282cbcc6e2e84a406460ce0 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:22 -0700
Subject: [PATCH 07/45] Chapter 8: Password updates (8f)
---
 app/auth/forms.py                       |  9 +++++++++
 app/auth/views.py                       | 18 +++++++++++++++++-
 app/templates/auth/change_password.html | 13 +++++++++++++
 app/templates/base.html                 |  8 +++++++-
 4 files changed, 46 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 cfaf89e38..582e6a176 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -33,3 +33,12 @@ 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(FlaskForm):
+    old_password = PasswordField('Old password', validators=[DataRequired()])
+    password = PasswordField('New password', validators=[
+        DataRequired(), EqualTo('password2', message='Passwords must match.')])
+    password2 = PasswordField('Confirm new password',
+                              validators=[DataRequired()])
+    submit = SubmitField('Update Password')
diff --git a/app/auth/views.py b/app/auth/views.py
index 9186daa4b..2f9cfd06c 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
@@ -86,3 +86,19 @@ 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)
+            db.session.commit()
+            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 @@
             
             
                 {% if current_user.is_authenticated %}
-                Log Out 
+                    Account  
+                    
+                 
                 {% else %}
                 Log In 
Date: Tue, 18 Jul 2017 07:55:23 -0700
Subject: [PATCH 08/45] Chapter 8: Password resets (8g)
---
 app/auth/forms.py                            | 13 +++++++
 app/auth/views.py                            | 36 +++++++++++++++++++-
 app/models.py                                | 18 ++++++++++
 app/templates/auth/email/reset_password.html |  8 +++++
 app/templates/auth/email/reset_password.txt  | 13 +++++++
 app/templates/auth/login.html                |  1 +
 app/templates/auth/reset_password.html       | 13 +++++++
 tests/test_user_model.py                     | 16 +++++++++
 8 files changed, 117 insertions(+), 1 deletion(-)
 create mode 100644 app/templates/auth/email/reset_password.html
 create mode 100644 app/templates/auth/email/reset_password.txt
 create mode 100644 app/templates/auth/reset_password.html
diff --git a/app/auth/forms.py b/app/auth/forms.py
index 582e6a176..c1bf9b7b2 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -42,3 +42,16 @@ class ChangePasswordForm(FlaskForm):
     password2 = PasswordField('Confirm new password',
                               validators=[DataRequired()])
     submit = SubmitField('Update Password')
+
+
+class PasswordResetRequestForm(FlaskForm):
+    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
+                                             Email()])
+    submit = SubmitField('Reset Password')
+
+
+class PasswordResetForm(FlaskForm):
+    password = PasswordField('New Password', validators=[
+        DataRequired(), EqualTo('password2', message='Passwords must match')])
+    password2 = PasswordField('Confirm password', validators=[DataRequired()])
+    submit = SubmitField('Reset Password')
diff --git a/app/auth/views.py b/app/auth/views.py
index 2f9cfd06c..be0db842d 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -5,7 +5,8 @@
 from .. import db
 from ..models import User
 from ..email import send_email
-from .forms import LoginForm, RegistrationForm, ChangePasswordForm
+from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
+    PasswordResetRequestForm, PasswordResetForm
 
 
 @auth.before_app_request
@@ -102,3 +103,36 @@ def change_password():
         else:
             flash('Invalid password.')
     return render_template("auth/change_password.html", form=form)
+
+
+@auth.route('/reset', methods=['GET', 'POST'])
+def password_reset_request():
+    if not current_user.is_anonymous:
+        return redirect(url_for('main.index'))
+    form = PasswordResetRequestForm()
+    if form.validate_on_submit():
+        user = User.query.filter_by(email=form.email.data.lower()).first()
+        if user:
+            token = user.generate_reset_token()
+            send_email(user.email, 'Reset Your Password',
+                       'auth/email/reset_password',
+                       user=user, token=token)
+        flash('An email with instructions to reset your password has been '
+              'sent to you.')
+        return redirect(url_for('auth.login'))
+    return render_template('auth/reset_password.html', form=form)
+
+
+@auth.route('/reset/', methods=['GET', 'POST'])
+def password_reset(token):
+    if not current_user.is_anonymous:
+        return redirect(url_for('main.index'))
+    form = PasswordResetForm()
+    if form.validate_on_submit():
+        if User.reset_password(token, form.password.data):
+            db.session.commit()
+            flash('Your password has been updated.')
+            return redirect(url_for('auth.login'))
+        else:
+            return redirect(url_for('main.index'))
+    return render_template('auth/reset_password.html', form=form)
diff --git a/app/models.py b/app/models.py
index 6c6030e83..0584cf9ca 100644
--- a/app/models.py
+++ b/app/models.py
@@ -51,6 +51,24 @@ def confirm(self, token):
         db.session.add(self)
         return True
 
+    def generate_reset_token(self, expiration=3600):
+        s = Serializer(current_app.config['SECRET_KEY'], expiration)
+        return s.dumps({'reset': self.id}).decode('utf-8')
+
+    @staticmethod
+    def reset_password(token, new_password):
+        s = Serializer(current_app.config['SECRET_KEY'])
+        try:
+            data = s.loads(token.encode('utf-8'))
+        except:
+            return False
+        user = User.query.get(data.get('reset'))
+        if user is None:
+            return False
+        user.password = new_password
+        db.session.add(user)
+        return True
+
     def __repr__(self):
         return '' % self.username
 
diff --git a/app/templates/auth/email/reset_password.html b/app/templates/auth/email/reset_password.html
new file mode 100644
index 000000000..1eafdfe16
--- /dev/null
+++ b/app/templates/auth/email/reset_password.html
@@ -0,0 +1,8 @@
+Dear {{ user.username }},
+To reset your password click here .
+Alternatively, you can paste the following link in your browser's address bar:
+{{ url_for('auth.password_reset', token=token, _external=True) }}
+If you have not requested a password reset simply ignore this message.
+Sincerely,
+The Flasky Team
+Note: replies to this email address are not monitored. 
diff --git a/app/templates/auth/email/reset_password.txt b/app/templates/auth/email/reset_password.txt
new file mode 100644
index 000000000..fc6826c07
--- /dev/null
+++ b/app/templates/auth/email/reset_password.txt
@@ -0,0 +1,13 @@
+Dear {{ user.username }},
+
+To reset your password click on the following link:
+
+{{ url_for('auth.password_reset', token=token, _external=True) }}
+
+If you have not requested a password reset simply ignore this message.
+
+Sincerely,
+
+The Flasky Team
+
+Note: replies to this email address are not monitored.
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
index 1e14c7f5a..136a7539b 100644
--- a/app/templates/auth/login.html
+++ b/app/templates/auth/login.html
@@ -10,6 +10,7 @@ Login 
 
 {% endblock %}
diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html
new file mode 100644
index 000000000..995007744
--- /dev/null
+++ b/app/templates/auth/reset_password.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Flasky - Password Reset{% endblock %}
+
+{% block page_content %}
+
+
+    {{ wtf.quick_form(form) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
index 4c8765774..8436e49b8 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -58,3 +58,19 @@ def test_expired_confirmation_token(self):
         token = u.generate_confirmation_token(1)
         time.sleep(2)
         self.assertFalse(u.confirm(token))
+
+    def test_valid_reset_token(self):
+        u = User(password='cat')
+        db.session.add(u)
+        db.session.commit()
+        token = u.generate_reset_token()
+        self.assertTrue(User.reset_password(token, 'dog'))
+        self.assertTrue(u.verify_password('dog'))
+
+    def test_invalid_reset_token(self):
+        u = User(password='cat')
+        db.session.add(u)
+        db.session.commit()
+        token = u.generate_reset_token()
+        self.assertFalse(User.reset_password(token + 'a', 'horse'))
+        self.assertTrue(u.verify_password('cat'))
From d5b2e68ba717b1f1bce2930d43c2099cab6998a5 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:24 -0700
Subject: [PATCH 09/45] Chapter 8: Email address changes (8h)
---
 app/auth/forms.py                          | 11 ++++++++
 app/auth/views.py                          | 32 +++++++++++++++++++++-
 app/models.py                              | 22 +++++++++++++++
 app/templates/auth/change_email.html       | 13 +++++++++
 app/templates/auth/email/change_email.html |  7 +++++
 app/templates/auth/email/change_email.txt  | 11 ++++++++
 app/templates/base.html                    |  1 +
 tests/test_user_model.py                   | 28 +++++++++++++++++++
 8 files changed, 124 insertions(+), 1 deletion(-)
 create mode 100644 app/templates/auth/change_email.html
 create mode 100644 app/templates/auth/email/change_email.html
 create mode 100644 app/templates/auth/email/change_email.txt
diff --git a/app/auth/forms.py b/app/auth/forms.py
index c1bf9b7b2..d59738dc5 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -55,3 +55,14 @@ class PasswordResetForm(FlaskForm):
         DataRequired(), EqualTo('password2', message='Passwords must match')])
     password2 = PasswordField('Confirm password', validators=[DataRequired()])
     submit = SubmitField('Reset Password')
+
+
+class ChangeEmailForm(FlaskForm):
+    email = StringField('New Email', validators=[DataRequired(), Length(1, 64),
+                                                 Email()])
+    password = PasswordField('Password', validators=[DataRequired()])
+    submit = SubmitField('Update Email Address')
+
+    def validate_email(self, field):
+        if User.query.filter_by(email=field.data.lower()).first():
+            raise ValidationError('Email already registered.')
diff --git a/app/auth/views.py b/app/auth/views.py
index be0db842d..f4a8aa487 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -6,7 +6,7 @@
 from ..models import User
 from ..email import send_email
 from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
-    PasswordResetRequestForm, PasswordResetForm
+    PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm
 
 
 @auth.before_app_request
@@ -136,3 +136,33 @@ def password_reset(token):
         else:
             return redirect(url_for('main.index'))
     return render_template('auth/reset_password.html', form=form)
+
+
+@auth.route('/change_email', methods=['GET', 'POST'])
+@login_required
+def change_email_request():
+    form = ChangeEmailForm()
+    if form.validate_on_submit():
+        if current_user.verify_password(form.password.data):
+            new_email = form.email.data.lower()
+            token = current_user.generate_email_change_token(new_email)
+            send_email(new_email, 'Confirm your email address',
+                       'auth/email/change_email',
+                       user=current_user, token=token)
+            flash('An email with instructions to confirm your new email '
+                  'address has been sent to you.')
+            return redirect(url_for('main.index'))
+        else:
+            flash('Invalid email or password.')
+    return render_template("auth/change_email.html", form=form)
+
+
+@auth.route('/change_email/')
+@login_required
+def change_email(token):
+    if current_user.change_email(token):
+        db.session.commit()
+        flash('Your email address has been updated.')
+    else:
+        flash('Invalid request.')
+    return redirect(url_for('main.index'))
diff --git a/app/models.py b/app/models.py
index 0584cf9ca..bfe4c6b50 100644
--- a/app/models.py
+++ b/app/models.py
@@ -69,6 +69,28 @@ def reset_password(token, new_password):
         db.session.add(user)
         return True
 
+    def generate_email_change_token(self, new_email, expiration=3600):
+        s = Serializer(current_app.config['SECRET_KEY'], expiration)
+        return s.dumps(
+            {'change_email': self.id, 'new_email': new_email}).decode('utf-8')
+
+    def change_email(self, token):
+        s = Serializer(current_app.config['SECRET_KEY'])
+        try:
+            data = s.loads(token.encode('utf-8'))
+        except:
+            return False
+        if data.get('change_email') != self.id:
+            return False
+        new_email = data.get('new_email')
+        if new_email is None:
+            return False
+        if self.query.filter_by(email=new_email).first() is not None:
+            return False
+        self.email = new_email
+        db.session.add(self)
+        return True
+
     def __repr__(self):
         return '' % self.username
 
diff --git a/app/templates/auth/change_email.html b/app/templates/auth/change_email.html
new file mode 100644
index 000000000..786b727a3
--- /dev/null
+++ b/app/templates/auth/change_email.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Flasky - Change Email Address{% endblock %}
+
+{% block page_content %}
+
+
+    {{ wtf.quick_form(form) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/email/change_email.html b/app/templates/auth/email/change_email.html
new file mode 100644
index 000000000..6d392a855
--- /dev/null
+++ b/app/templates/auth/email/change_email.html
@@ -0,0 +1,7 @@
+Dear {{ user.username }},
+To confirm your new email address click here .
+Alternatively, you can paste the following link in your browser's address bar:
+{{ url_for('auth.change_email', token=token, _external=True) }}
+Sincerely,
+The Flasky Team
+Note: replies to this email address are not monitored. 
diff --git a/app/templates/auth/email/change_email.txt b/app/templates/auth/email/change_email.txt
new file mode 100644
index 000000000..d94902e10
--- /dev/null
+++ b/app/templates/auth/email/change_email.txt
@@ -0,0 +1,11 @@
+Dear {{ user.username }},
+
+To confirm your new email address click on the following link:
+
+{{ url_for('auth.change_email', token=token, _external=True) }}
+
+Sincerely,
+
+The Flasky Team
+
+Note: replies to this email address are not monitored.
diff --git a/app/templates/base.html b/app/templates/base.html
index cd96c3338..1ab3e54cd 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -30,6 +30,7 @@
                     Account  
                     
                 
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
index 8436e49b8..201b3bac8 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -74,3 +74,31 @@ def test_invalid_reset_token(self):
         token = u.generate_reset_token()
         self.assertFalse(User.reset_password(token + 'a', 'horse'))
         self.assertTrue(u.verify_password('cat'))
+
+    def test_valid_email_change_token(self):
+        u = User(email='john@example.com', password='cat')
+        db.session.add(u)
+        db.session.commit()
+        token = u.generate_email_change_token('susan@example.org')
+        self.assertTrue(u.change_email(token))
+        self.assertTrue(u.email == 'susan@example.org')
+
+    def test_invalid_email_change_token(self):
+        u1 = User(email='john@example.com', password='cat')
+        u2 = User(email='susan@example.org', password='dog')
+        db.session.add(u1)
+        db.session.add(u2)
+        db.session.commit()
+        token = u1.generate_email_change_token('david@example.net')
+        self.assertFalse(u2.change_email(token))
+        self.assertTrue(u2.email == 'susan@example.org')
+
+    def test_duplicate_email_change_token(self):
+        u1 = User(email='john@example.com', password='cat')
+        u2 = User(email='susan@example.org', password='dog')
+        db.session.add(u1)
+        db.session.add(u2)
+        db.session.commit()
+        token = u2.generate_email_change_token('john@example.com')
+        self.assertFalse(u2.change_email(token))
+        self.assertTrue(u2.email == 'susan@example.org')
From 0fe030beb1c17f8dea06f44d4f4d4c338f580b16 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:25 -0700
Subject: [PATCH 10/45] Chapter 9: User roles and permissions (9a)
---
 app/decorators.py                             | 19 +++++
 app/main/__init__.py                          |  6 ++
 app/main/errors.py                            |  5 ++
 app/models.py                                 | 77 ++++++++++++++++++-
 app/templates/403.html                        |  9 +++
 flasky.py                                     |  4 +-
 .../versions/56ed7d33de8d_user_roles.py       | 30 ++++++++
 tests/test_user_model.py                      | 37 ++++++++-
 8 files changed, 183 insertions(+), 4 deletions(-)
 create mode 100644 app/decorators.py
 create mode 100644 app/templates/403.html
 create mode 100644 migrations/versions/56ed7d33de8d_user_roles.py
diff --git a/app/decorators.py b/app/decorators.py
new file mode 100644
index 000000000..14ddc0347
--- /dev/null
+++ b/app/decorators.py
@@ -0,0 +1,19 @@
+from functools import wraps
+from flask import abort
+from flask_login import current_user
+from .models import Permission
+
+
+def permission_required(permission):
+    def decorator(f):
+        @wraps(f)
+        def decorated_function(*args, **kwargs):
+            if not current_user.can(permission):
+                abort(403)
+            return f(*args, **kwargs)
+        return decorated_function
+    return decorator
+
+
+def admin_required(f):
+    return permission_required(Permission.ADMIN)(f)
diff --git a/app/main/__init__.py b/app/main/__init__.py
index 90380f84d..ef760402f 100644
--- a/app/main/__init__.py
+++ b/app/main/__init__.py
@@ -3,3 +3,9 @@
 main = Blueprint('main', __name__)
 
 from . import views, errors
+from ..models import Permission
+
+
+@main.app_context_processor
+def inject_permissions():
+    return dict(Permission=Permission)
diff --git a/app/main/errors.py b/app/main/errors.py
index 7c76c776d..416c15142 100644
--- a/app/main/errors.py
+++ b/app/main/errors.py
@@ -2,6 +2,11 @@
 from . import main
 
 
+@main.app_errorhandler(403)
+def forbidden(e):
+    return render_template('403.html'), 403
+
+
 @main.app_errorhandler(404)
 def page_not_found(e):
     return render_template('404.html'), 404
diff --git a/app/models.py b/app/models.py
index bfe4c6b50..c00a213eb 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,16 +1,67 @@
 from werkzeug.security import generate_password_hash, check_password_hash
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
 from flask import current_app
-from flask_login import UserMixin
+from flask_login import UserMixin, AnonymousUserMixin
 from . import db, login_manager
 
 
+class Permission:
+    FOLLOW = 1
+    COMMENT = 2
+    WRITE = 4
+    MODERATE = 8
+    ADMIN = 16
+
+
 class Role(db.Model):
     __tablename__ = 'roles'
     id = db.Column(db.Integer, primary_key=True)
     name = db.Column(db.String(64), unique=True)
+    default = db.Column(db.Boolean, default=False, index=True)
+    permissions = db.Column(db.Integer)
     users = db.relationship('User', backref='role', lazy='dynamic')
 
+    def __init__(self, **kwargs):
+        super(Role, self).__init__(**kwargs)
+        if self.permissions is None:
+            self.permissions = 0
+
+    @staticmethod
+    def insert_roles():
+        roles = {
+            'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
+            'Moderator': [Permission.FOLLOW, Permission.COMMENT,
+                          Permission.WRITE, Permission.MODERATE],
+            'Administrator': [Permission.FOLLOW, Permission.COMMENT,
+                              Permission.WRITE, Permission.MODERATE,
+                              Permission.ADMIN],
+        }
+        default_role = 'User'
+        for r in roles:
+            role = Role.query.filter_by(name=r).first()
+            if role is None:
+                role = Role(name=r)
+            role.reset_permissions()
+            for perm in roles[r]:
+                role.add_permission(perm)
+            role.default = (role.name == default_role)
+            db.session.add(role)
+        db.session.commit()
+
+    def add_permission(self, perm):
+        if not self.has_permission(perm):
+            self.permissions += perm
+
+    def remove_permission(self, perm):
+        if self.has_permission(perm):
+            self.permissions -= perm
+
+    def reset_permissions(self):
+        self.permissions = 0
+
+    def has_permission(self, perm):
+        return self.permissions & perm == perm
+
     def __repr__(self):
         return '' % self.name
 
@@ -24,6 +75,14 @@ class User(UserMixin, db.Model):
     password_hash = db.Column(db.String(128))
     confirmed = db.Column(db.Boolean, default=False)
 
+    def __init__(self, **kwargs):
+        super(User, self).__init__(**kwargs)
+        if self.role is None:
+            if self.email == current_app.config['FLASKY_ADMIN']:
+                self.role = Role.query.filter_by(name='Administrator').first()
+            if self.role is None:
+                self.role = Role.query.filter_by(default=True).first()
+
     @property
     def password(self):
         raise AttributeError('password is not a readable attribute')
@@ -91,10 +150,26 @@ def change_email(self, token):
         db.session.add(self)
         return True
 
+    def can(self, perm):
+        return self.role is not None and self.role.has_permission(perm)
+
+    def is_administrator(self):
+        return self.can(Permission.ADMIN)
+
     def __repr__(self):
         return '' % self.username
 
 
+class AnonymousUser(AnonymousUserMixin):
+    def can(self, permissions):
+        return False
+
+    def is_administrator(self):
+        return False
+
+login_manager.anonymous_user = AnonymousUser
+
+
 @login_manager.user_loader
 def load_user(user_id):
     return User.query.get(int(user_id))
diff --git a/app/templates/403.html b/app/templates/403.html
new file mode 100644
index 000000000..9541b9e8d
--- /dev/null
+++ b/app/templates/403.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Forbidden{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
diff --git a/flasky.py b/flasky.py
index 8a4d1adca..31b798c89 100644
--- a/flasky.py
+++ b/flasky.py
@@ -2,7 +2,7 @@
 import click
 from flask_migrate import Migrate
 from app import create_app, db
-from app.models import User, Role
+from app.models import User, Role, Permission
 
 app = create_app(os.getenv('FLASK_CONFIG') or 'default')
 migrate = Migrate(app, db)
@@ -10,7 +10,7 @@
 
 @app.shell_context_processor
 def make_shell_context():
-    return dict(db=db, User=User, Role=Role)
+    return dict(db=db, User=User, Role=Role, Permission=Permission)
 
 
 @app.cli.command()
diff --git a/migrations/versions/56ed7d33de8d_user_roles.py b/migrations/versions/56ed7d33de8d_user_roles.py
new file mode 100644
index 000000000..15b68729a
--- /dev/null
+++ b/migrations/versions/56ed7d33de8d_user_roles.py
@@ -0,0 +1,30 @@
+"""user roles
+
+Revision ID: 56ed7d33de8d
+Revises: 190163627111
+Create Date: 2013-12-29 22:19:54.212604
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '56ed7d33de8d'
+down_revision = '190163627111'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('roles', sa.Column('default', sa.Boolean(), nullable=True))
+    op.add_column('roles', sa.Column('permissions', sa.Integer(), nullable=True))
+    op.create_index('ix_roles_default', 'roles', ['default'], unique=False)
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('ix_roles_default', 'roles')
+    op.drop_column('roles', 'permissions')
+    op.drop_column('roles', 'default')
+    ### end Alembic commands ###
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
index 201b3bac8..89aa5c9a4 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -1,7 +1,7 @@
 import unittest
 import time
 from app import create_app, db
-from app.models import User
+from app.models import User, AnonymousUser, Role, Permission
 
 
 class UserModelTestCase(unittest.TestCase):
@@ -10,6 +10,7 @@ def setUp(self):
         self.app_context = self.app.app_context()
         self.app_context.push()
         db.create_all()
+        Role.insert_roles()
 
     def tearDown(self):
         db.session.remove()
@@ -102,3 +103,37 @@ def test_duplicate_email_change_token(self):
         token = u2.generate_email_change_token('john@example.com')
         self.assertFalse(u2.change_email(token))
         self.assertTrue(u2.email == 'susan@example.org')
+
+    def test_user_role(self):
+        u = User(email='john@example.com', password='cat')
+        self.assertTrue(u.can(Permission.FOLLOW))
+        self.assertTrue(u.can(Permission.COMMENT))
+        self.assertTrue(u.can(Permission.WRITE))
+        self.assertFalse(u.can(Permission.MODERATE))
+        self.assertFalse(u.can(Permission.ADMIN))
+
+    def test_moderator_role(self):
+        r = Role.query.filter_by(name='Moderator').first()
+        u = User(email='john@example.com', password='cat', role=r)
+        self.assertTrue(u.can(Permission.FOLLOW))
+        self.assertTrue(u.can(Permission.COMMENT))
+        self.assertTrue(u.can(Permission.WRITE))
+        self.assertTrue(u.can(Permission.MODERATE))
+        self.assertFalse(u.can(Permission.ADMIN))
+
+    def test_administrator_role(self):
+        r = Role.query.filter_by(name='Administrator').first()
+        u = User(email='john@example.com', password='cat', role=r)
+        self.assertTrue(u.can(Permission.FOLLOW))
+        self.assertTrue(u.can(Permission.COMMENT))
+        self.assertTrue(u.can(Permission.WRITE))
+        self.assertTrue(u.can(Permission.MODERATE))
+        self.assertTrue(u.can(Permission.ADMIN))
+
+    def test_anonymous_user(self):
+        u = AnonymousUser()
+        self.assertFalse(u.can(Permission.FOLLOW))
+        self.assertFalse(u.can(Permission.COMMENT))
+        self.assertFalse(u.can(Permission.WRITE))
+        self.assertFalse(u.can(Permission.MODERATE))
+        self.assertFalse(u.can(Permission.ADMIN))
From 6837b087ee57e6998ab1e296287c7357b26ccf62 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:26 -0700
Subject: [PATCH 11/45] Chapter 10: User profiles (10a)
---
 app/auth/views.py                             | 13 +++----
 app/main/views.py                             |  9 ++++-
 app/models.py                                 | 10 ++++++
 app/templates/base.html                       |  3 ++
 app/templates/user.html                       | 22 ++++++++++++
 .../versions/d66f086b258_user_information.py  | 34 +++++++++++++++++++
 tests/test_user_model.py                      | 19 +++++++++++
 7 files changed, 103 insertions(+), 7 deletions(-)
 create mode 100644 app/templates/user.html
 create mode 100644 migrations/versions/d66f086b258_user_information.py
diff --git a/app/auth/views.py b/app/auth/views.py
index f4a8aa487..7ddd75ea2 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -11,12 +11,13 @@
 
 @auth.before_app_request
 def before_request():
-    if current_user.is_authenticated \
-            and not current_user.confirmed \
-            and request.endpoint \
-            and request.blueprint != 'auth' \
-            and request.endpoint != 'static':
-        return redirect(url_for('auth.unconfirmed'))
+    if current_user.is_authenticated:
+        current_user.ping()
+        if not current_user.confirmed \
+                and request.endpoint \
+                and request.blueprint != 'auth' \
+                and request.endpoint != 'static':
+            return redirect(url_for('auth.unconfirmed'))
 
 
 @auth.route('/unconfirmed')
diff --git a/app/main/views.py b/app/main/views.py
index c8520dea6..607def452 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,7 +1,14 @@
-from flask import render_template
+from flask import render_template, abort
 from . import main
+from ..models import User
 
 
 @main.route('/')
 def index():
     return render_template('index.html')
+
+
+@main.route('/user/')
+def user(username):
+    user = User.query.filter_by(username=username).first_or_404()
+    return render_template('user.html', user=user)
diff --git a/app/models.py b/app/models.py
index c00a213eb..58b719286 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,3 +1,4 @@
+from datetime import datetime
 from werkzeug.security import generate_password_hash, check_password_hash
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
 from flask import current_app
@@ -74,6 +75,11 @@ class User(UserMixin, db.Model):
     role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
     password_hash = db.Column(db.String(128))
     confirmed = db.Column(db.Boolean, default=False)
+    name = db.Column(db.String(64))
+    location = db.Column(db.String(64))
+    about_me = db.Column(db.Text())
+    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
+    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
 
     def __init__(self, **kwargs):
         super(User, self).__init__(**kwargs)
@@ -156,6 +162,10 @@ def can(self, perm):
     def is_administrator(self):
         return self.can(Permission.ADMIN)
 
+    def ping(self):
+        self.last_seen = datetime.utcnow()
+        db.session.add(self)
+
     def __repr__(self):
         return '' % self.username
 
diff --git a/app/templates/base.html b/app/templates/base.html
index 1ab3e54cd..3d32dabc6 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -23,6 +23,9 @@
         
             
                 Home Profile  
             
                 {% if current_user.is_authenticated %}
diff --git a/app/templates/user.html b/app/templates/user.html
new file mode 100644
index 000000000..fbe2b216c
--- /dev/null
+++ b/app/templates/user.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - {{ user.username }}{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/migrations/versions/d66f086b258_user_information.py b/migrations/versions/d66f086b258_user_information.py
new file mode 100644
index 000000000..6ff6c05e0
--- /dev/null
+++ b/migrations/versions/d66f086b258_user_information.py
@@ -0,0 +1,34 @@
+"""user information
+
+Revision ID: d66f086b258
+Revises: 56ed7d33de8d
+Create Date: 2013-12-29 23:50:49.566954
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'd66f086b258'
+down_revision = '56ed7d33de8d'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('about_me', sa.Text(), nullable=True))
+    op.add_column('users', sa.Column('last_seen', sa.DateTime(), nullable=True))
+    op.add_column('users', sa.Column('location', sa.String(length=64), nullable=True))
+    op.add_column('users', sa.Column('member_since', sa.DateTime(), nullable=True))
+    op.add_column('users', sa.Column('name', sa.String(length=64), nullable=True))
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'name')
+    op.drop_column('users', 'member_since')
+    op.drop_column('users', 'location')
+    op.drop_column('users', 'last_seen')
+    op.drop_column('users', 'about_me')
+    ### end Alembic commands ###
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
index 89aa5c9a4..5b566538e 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -1,5 +1,6 @@
 import unittest
 import time
+from datetime import datetime
 from app import create_app, db
 from app.models import User, AnonymousUser, Role, Permission
 
@@ -137,3 +138,21 @@ def test_anonymous_user(self):
         self.assertFalse(u.can(Permission.WRITE))
         self.assertFalse(u.can(Permission.MODERATE))
         self.assertFalse(u.can(Permission.ADMIN))
+
+    def test_timestamps(self):
+        u = User(password='cat')
+        db.session.add(u)
+        db.session.commit()
+        self.assertTrue(
+            (datetime.utcnow() - u.member_since).total_seconds() < 3)
+        self.assertTrue(
+            (datetime.utcnow() - u.last_seen).total_seconds() < 3)
+
+    def test_ping(self):
+        u = User(password='cat')
+        db.session.add(u)
+        db.session.commit()
+        time.sleep(2)
+        last_seen_before = u.last_seen
+        u.ping()
+        self.assertTrue(u.last_seen > last_seen_before)
From 6aa8227bba97eb5f3a63b98e4ab808c2090555a8 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:27 -0700
Subject: [PATCH 12/45] Chapter 10: Profiles editor (10b)
---
 app/main/forms.py               | 46 ++++++++++++++++++++++++++--
 app/main/views.py               | 54 +++++++++++++++++++++++++++++++--
 app/templates/edit_profile.html | 13 ++++++++
 app/templates/user.html         |  8 +++++
 4 files changed, 117 insertions(+), 4 deletions(-)
 create mode 100644 app/templates/edit_profile.html
diff --git a/app/main/forms.py b/app/main/forms.py
index 2ca927755..b8c59be56 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -1,8 +1,50 @@
 from flask_wtf import FlaskForm
-from wtforms import StringField, SubmitField
-from wtforms.validators import DataRequired
+from wtforms import StringField, TextAreaField, BooleanField, SelectField,\
+    SubmitField
+from wtforms.validators import DataRequired, Length, Email, Regexp
+from wtforms import ValidationError
+from ..models import Role, User
 
 
 class NameForm(FlaskForm):
     name = StringField('What is your name?', validators=[DataRequired()])
     submit = SubmitField('Submit')
+
+
+class EditProfileForm(FlaskForm):
+    name = StringField('Real name', validators=[Length(0, 64)])
+    location = StringField('Location', validators=[Length(0, 64)])
+    about_me = TextAreaField('About me')
+    submit = SubmitField('Submit')
+
+
+class EditProfileAdminForm(FlaskForm):
+    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
+                                             Email()])
+    username = StringField('Username', validators=[
+        DataRequired(), Length(1, 64),
+        Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
+               'Usernames must have only letters, numbers, dots or '
+               'underscores')])
+    confirmed = BooleanField('Confirmed')
+    role = SelectField('Role', coerce=int)
+    name = StringField('Real name', validators=[Length(0, 64)])
+    location = StringField('Location', validators=[Length(0, 64)])
+    about_me = TextAreaField('About me')
+    submit = SubmitField('Submit')
+
+    def __init__(self, user, *args, **kwargs):
+        super(EditProfileAdminForm, self).__init__(*args, **kwargs)
+        self.role.choices = [(role.id, role.name)
+                             for role in Role.query.order_by(Role.name).all()]
+        self.user = user
+
+    def validate_email(self, field):
+        if field.data != self.user.email and \
+                User.query.filter_by(email=field.data).first():
+            raise ValidationError('Email already registered.')
+
+    def validate_username(self, field):
+        if field.data != self.user.username and \
+                User.query.filter_by(username=field.data).first():
+            raise ValidationError('Username already in use.')
diff --git a/app/main/views.py b/app/main/views.py
index 607def452..78e196674 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,6 +1,10 @@
-from flask import render_template, abort
+from flask import render_template, redirect, url_for, abort, flash
+from flask_login import login_required, current_user
 from . import main
-from ..models import User
+from .forms import EditProfileForm, EditProfileAdminForm
+from .. import db
+from ..models import Role, User
+from ..decorators import admin_required
 
 
 @main.route('/')
@@ -12,3 +16,49 @@ def index():
 def user(username):
     user = User.query.filter_by(username=username).first_or_404()
     return render_template('user.html', user=user)
+
+
+@main.route('/edit-profile', methods=['GET', 'POST'])
+@login_required
+def edit_profile():
+    form = EditProfileForm()
+    if form.validate_on_submit():
+        current_user.name = form.name.data
+        current_user.location = form.location.data
+        current_user.about_me = form.about_me.data
+        db.session.add(current_user._get_current_object())
+        db.session.commit()
+        flash('Your profile has been updated.')
+        return redirect(url_for('.user', username=current_user.username))
+    form.name.data = current_user.name
+    form.location.data = current_user.location
+    form.about_me.data = current_user.about_me
+    return render_template('edit_profile.html', form=form)
+
+
+@main.route('/edit-profile/', methods=['GET', 'POST'])
+@login_required
+@admin_required
+def edit_profile_admin(id):
+    user = User.query.get_or_404(id)
+    form = EditProfileAdminForm(user=user)
+    if form.validate_on_submit():
+        user.email = form.email.data
+        user.username = form.username.data
+        user.confirmed = form.confirmed.data
+        user.role = Role.query.get(form.role.data)
+        user.name = form.name.data
+        user.location = form.location.data
+        user.about_me = form.about_me.data
+        db.session.add(user)
+        db.session.commit()
+        flash('The profile has been updated.')
+        return redirect(url_for('.user', username=user.username))
+    form.email.data = user.email
+    form.username.data = user.username
+    form.confirmed.data = user.confirmed
+    form.role.data = user.role_id
+    form.name.data = user.name
+    form.location.data = user.location
+    form.about_me.data = user.about_me
+    return render_template('edit_profile.html', form=form, user=user)
diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html
new file mode 100644
index 000000000..44bd7fae1
--- /dev/null
+++ b/app/templates/edit_profile.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Flasky - Edit Profile{% endblock %}
+
+{% block page_content %}
+
+
+    {{ wtf.quick_form(form) }}
+
+{% endblock %}
diff --git a/app/templates/user.html b/app/templates/user.html
index fbe2b216c..c2542bd60 100644
--- a/app/templates/user.html
+++ b/app/templates/user.html
@@ -18,5 +18,13 @@ {{ user.username }} 
     {% endif %}
     {% if user.about_me %}{{ user.about_me }}
{% endif %}
     Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.
+    
+        {% if user == current_user %}
+        Edit Profile 
+        {% endif %}
+        {% if current_user.is_administrator() %}
+        Edit Profile [Admin] 
+        {% endif %}
+    
     
 {% endblock %}
\ No newline at end of file
From efeacf0519549dc672f14bc8e8fefbce25adab92 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:28 -0700
Subject: [PATCH 13/45] Chapter 10: User avatars (10c)
---
 app/models.py            |  9 ++++++++-
 app/static/styles.css    |  8 ++++++++
 app/templates/base.html  |  6 +++++-
 app/templates/user.html  | 41 +++++++++++++++++++++-------------------
 tests/test_user_model.py | 17 +++++++++++++++++
 5 files changed, 60 insertions(+), 21 deletions(-)
 create mode 100644 app/static/styles.css
diff --git a/app/models.py b/app/models.py
index 58b719286..4bc6b2ef5 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,7 +1,8 @@
 from datetime import datetime
+import hashlib
 from werkzeug.security import generate_password_hash, check_password_hash
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
-from flask import current_app
+from flask import current_app, request
 from flask_login import UserMixin, AnonymousUserMixin
 from . import db, login_manager
 
@@ -166,6 +167,12 @@ def ping(self):
         self.last_seen = datetime.utcnow()
         db.session.add(self)
 
+    def gravatar(self, size=100, default='identicon', rating='g'):
+        url = 'https://secure.gravatar.com/avatar'
+        hash = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
+        return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
+            url=url, hash=hash, size=size, default=default, rating=rating)
+
     def __repr__(self):
         return '' % self.username
 
diff --git a/app/static/styles.css b/app/static/styles.css
new file mode 100644
index 000000000..01f8f826b
--- /dev/null
+++ b/app/static/styles.css
@@ -0,0 +1,8 @@
+.profile-thumbnail {
+    position: absolute;
+}
+.profile-header {
+    min-height: 260px;
+    margin-left: 280px;
+}
+
diff --git a/app/templates/base.html b/app/templates/base.html
index 3d32dabc6..edd5640f2 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -6,6 +6,7 @@
 {{ super() }}
                                  
     
 
+Posts by {{ user.username }} 
+{% include '_posts.html' %}
 {% endblock %}
\ No newline at end of file
From 8d073ae48e0aa99d9602e89209aa4157fc0ad1ce Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:32 -0700
Subject: [PATCH 17/45] Chapter 11: Generate fake users and posts (11c)
---
 app/fake.py                                 | 37 +++++++++++++++++++++
 requirements.txt => requirements/common.txt |  0
 requirements/dev.txt                        |  2 ++
 requirements/prod.txt                       |  1 +
 4 files changed, 40 insertions(+)
 create mode 100644 app/fake.py
 rename requirements.txt => requirements/common.txt (100%)
 create mode 100644 requirements/dev.txt
 create mode 100644 requirements/prod.txt
diff --git a/app/fake.py b/app/fake.py
new file mode 100644
index 000000000..bdf52fc31
--- /dev/null
+++ b/app/fake.py
@@ -0,0 +1,37 @@
+from random import randint
+from sqlalchemy.exc import IntegrityError
+from faker import Faker
+from . import db
+from .models import User, Post
+
+
+def users(count=100):
+    fake = Faker()
+    i = 0
+    while i < count:
+        u = User(email=fake.email(),
+                 username=fake.user_name(),
+                 password='password',
+                 confirmed=True,
+                 name=fake.name(),
+                 location=fake.city(),
+                 about_me=fake.text(),
+                 member_since=fake.past_date())
+        db.session.add(u)
+        try:
+            db.session.commit()
+            i += 1
+        except IntegrityError:
+            db.session.rollback()
+
+
+def posts(count=100):
+    fake = Faker()
+    user_count = User.query.count()
+    for i in range(count):
+        u = User.query.offset(randint(0, user_count - 1)).first()
+        p = Post(body=fake.text(),
+                 timestamp=fake.past_date(),
+                 author=u)
+        db.session.add(p)
+    db.session.commit()
diff --git a/requirements.txt b/requirements/common.txt
similarity index 100%
rename from requirements.txt
rename to requirements/common.txt
diff --git a/requirements/dev.txt b/requirements/dev.txt
new file mode 100644
index 000000000..7044abc86
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,2 @@
+-r common.txt
+faker==0.7.18
diff --git a/requirements/prod.txt b/requirements/prod.txt
new file mode 100644
index 000000000..6624a3020
--- /dev/null
+++ b/requirements/prod.txt
@@ -0,0 +1 @@
+-r common.txt
From 460311926c6a17f8190ce5ecab3e30dd9c80373a Mon Sep 17 00:00:00 2001
From: Miguel Grinberg 
Date: Tue, 18 Jul 2017 07:55:33 -0700
Subject: [PATCH 18/45] Chapter 11: Blog post pagination (11d)
---
 app/main/views.py          | 21 ++++++++++++++++-----
 app/static/styles.css      |  7 +++++++
 app/templates/_macros.html | 29 +++++++++++++++++++++++++++++
 app/templates/index.html   |  6 ++++++
 app/templates/user.html    |  6 ++++++
 config.py                  |  1 +
 6 files changed, 65 insertions(+), 5 deletions(-)
 create mode 100644 app/templates/_macros.html
diff --git a/app/main/views.py b/app/main/views.py
index 82b776fce..7b6578289 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,4 +1,5 @@
-from flask import render_template, redirect, url_for, abort, flash
+from flask import render_template, redirect, url_for, abort, flash, request,\
+    current_app
 from flask_login import login_required, current_user
 from . import main
 from .forms import EditProfileForm, EditProfileAdminForm, PostForm
@@ -16,15 +17,25 @@ def index():
         db.session.add(post)
         db.session.commit()
         return redirect(url_for('.index'))
-    posts = Post.query.order_by(Post.timestamp.desc()).all()
-    return render_template('index.html', form=form, posts=posts)
+    page = request.args.get('page', 1, type=int)
+    pagination = Post.query.order_by(Post.timestamp.desc()).paginate(
+        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
+        error_out=False)
+    posts = pagination.items
+    return render_template('index.html', form=form, posts=posts,
+                           pagination=pagination)
 
 
 @main.route('/user/')
 def user(username):
     user = User.query.filter_by(username=username).first_or_404()
-    posts = user.posts.order_by(Post.timestamp.desc()).all()
-    return render_template('user.html', user=user, posts=posts)
+    page = request.args.get('page', 1, type=int)
+    pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
+        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
+        error_out=False)
+    posts = pagination.items
+    return render_template('user.html', user=user, posts=posts,
+                           pagination=pagination)
 
 
 @main.route('/edit-profile', methods=['GET', 'POST'])
diff --git a/app/static/styles.css b/app/static/styles.css
index 153c2f200..06673c7e5 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -31,3 +31,10 @@ div.post-content {
     margin-left: 48px;
     min-height: 48px;
 }
+div.pagination {
+    width: 100%;
+    text-align: right;
+    padding: 0px;
+    margin: 0px;
+}
+
diff --git a/app/templates/_macros.html b/app/templates/_macros.html
new file mode 100644
index 000000000..b5d55a394
--- /dev/null
+++ b/app/templates/_macros.html
@@ -0,0 +1,29 @@
+{% macro pagination_widget(pagination, endpoint) %}
+
+{% endmacro %}
diff --git a/app/templates/index.html b/app/templates/index.html
index 19a37407e..2533d7b7c 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -1,5 +1,6 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
+{% import "_macros.html" as macros %}
 
 {% block title %}Flasky{% endblock %}
 
@@ -13,4 +14,9 @@