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
+ {% else %}
+ Log In
+ {% endif %}
+
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
{% endif %}
From f10e11e3c2f3bc4ccaaaebda5ca2c74866ddc885 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
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
+ {% if current_user.is_authenticated %}
+ Profile
+ {% endif %}
{% 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() }}
+
{% endblock %}
{% block navbar %}
@@ -30,7 +31,10 @@
+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 @@