From 18fbe65523fe44bf95cca6ae72dc9aa52e9e933e Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Sep 2013 16:48:35 -0700
Subject: [PATCH 01/59] Initial commit
---
hello.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 hello.py
diff --git a/hello.py b/hello.py
new file mode 100644
index 000000000..e69de29bb
From 8466609343f30b031ebf9380a33fb34be4e9110f Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 01:35:04 -0800
Subject: [PATCH 02/59] Chapter 1: initial version (1a)
---
.gitignore | 42 ++++++++++++++++++++++++++++++++++++++++++
LICENSE | 21 +++++++++++++++++++++
README.md | 7 +++++++
3 files changed, 70 insertions(+)
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 README.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..37ce1aa50
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+*.py[cod]
+
+# C extensions
+*.so
+
+# Packages
+*.egg
+*.egg-info
+dist
+build
+eggs
+parts
+bin
+var
+sdist
+develop-eggs
+.installed.cfg
+lib
+lib64
+__pycache__
+
+# Installer logs
+pip-log.txt
+
+# Unit test / coverage reports
+.coverage
+.tox
+nosetests.xml
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+
+# SQLite databases
+*.sqlite
+
+# Virtual environment
+venv
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..2e7905471
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2013 Miguel Grinberg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..2088fa41d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+Flasky
+======
+
+This repository contains the source code examples for my O'Reilly book [Flask Web Development](http://www.flaskbook.com).
+
+The commits and tags in this repository were carefully created to match the sequence in which concepts are presented in the book. Please read the section titled "How to Work with the Example Code" in the book's preface for instructions.
+
From 52a780d6542e37f0e7b6a919bf3d7861338e588c Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 22:58:48 -0800
Subject: [PATCH 03/59] Chapter 2: A complete application (2a)
---
hello.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/hello.py b/hello.py
index e69de29bb..062cbc298 100644
--- a/hello.py
+++ b/hello.py
@@ -0,0 +1,11 @@
+from flask import Flask
+app = Flask(__name__)
+
+
+@app.route('/')
+def index():
+ return 'Hello World! '
+
+
+if __name__ == '__main__':
+ app.run(debug=True)
From 1970f982b6bf1baabb13bd802969dc5e3ffa8030 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:04:50 -0800
Subject: [PATCH 04/59] Chapter 2: Dynamic routes (2b)
---
hello.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/hello.py b/hello.py
index 062cbc298..2bf2aedcd 100644
--- a/hello.py
+++ b/hello.py
@@ -7,5 +7,10 @@ def index():
return 'Hello World! '
+@app.route('/user/')
+def user(name):
+ return 'Hello, %s! ' % name
+
+
if __name__ == '__main__':
app.run(debug=True)
From 9405cbd5c52a7e6c8d423e545ffbaa08dc59849e Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 00:37:59 -0800
Subject: [PATCH 05/59] Chapter 2: Command line options with Flask-Script (2c)
---
hello.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index 2bf2aedcd..e58a0d9fd 100644
--- a/hello.py
+++ b/hello.py
@@ -1,6 +1,10 @@
from flask import Flask
+from flask.ext.script import Manager
+
app = Flask(__name__)
+manager = Manager(app)
+
@app.route('/')
def index():
@@ -13,4 +17,4 @@ def user(name):
if __name__ == '__main__':
- app.run(debug=True)
+ manager.run()
From e55fc2f5f6eba0c73bcdc3fa5c1608d9643a7340 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:01:48 -0800
Subject: [PATCH 06/59] Chapter 3: Templates (3a)
---
hello.py | 6 +++---
templates/index.html | 1 +
templates/user.html | 1 +
3 files changed, 5 insertions(+), 3 deletions(-)
create mode 100644 templates/index.html
create mode 100644 templates/user.html
diff --git a/hello.py b/hello.py
index e58a0d9fd..fccbfa2b1 100644
--- a/hello.py
+++ b/hello.py
@@ -1,4 +1,4 @@
-from flask import Flask
+from flask import Flask, render_template
from flask.ext.script import Manager
app = Flask(__name__)
@@ -8,12 +8,12 @@
@app.route('/')
def index():
- return 'Hello World! '
+ return render_template('index.html')
@app.route('/user/')
def user(name):
- return 'Hello, %s! ' % name
+ return render_template('user.html', name=name)
if __name__ == '__main__':
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 000000000..de8b69b6e
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1 @@
+Hello World!
diff --git a/templates/user.html b/templates/user.html
new file mode 100644
index 000000000..33fdc85ef
--- /dev/null
+++ b/templates/user.html
@@ -0,0 +1 @@
+Hello, {{ name }}!
From 2cbde43e0588a6ba03943f9634ae60099c535732 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:12:43 -0800
Subject: [PATCH 07/59] Chapter 3: Templates with Flask-Bootstrap (3b)
---
hello.py | 2 ++
templates/user.html | 33 ++++++++++++++++++++++++++++++++-
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index fccbfa2b1..ed60dfa4c 100644
--- a/hello.py
+++ b/hello.py
@@ -1,9 +1,11 @@
from flask import Flask, render_template
from flask.ext.script import Manager
+from flask.ext.bootstrap import Bootstrap
app = Flask(__name__)
manager = Manager(app)
+bootstrap = Bootstrap(app)
@app.route('/')
diff --git a/templates/user.html b/templates/user.html
index 33fdc85ef..09b792d50 100644
--- a/templates/user.html
+++ b/templates/user.html
@@ -1 +1,32 @@
-Hello, {{ name }}!
+{% extends "bootstrap/base.html" %}
+
+{% block title %}Flasky{% endblock %}
+
+{% block navbar %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
From c2a213b4fb393dbb5d549eed0adc9f95d70fe199 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:31:24 -0800
Subject: [PATCH 08/59] Chapter 3: Custom error pages (3c)
---
hello.py | 10 ++++++++++
templates/404.html | 9 +++++++++
templates/500.html | 9 +++++++++
templates/base.html | 30 ++++++++++++++++++++++++++++++
templates/index.html | 10 +++++++++-
templates/user.html | 31 ++++---------------------------
6 files changed, 71 insertions(+), 28 deletions(-)
create mode 100644 templates/404.html
create mode 100644 templates/500.html
create mode 100644 templates/base.html
diff --git a/hello.py b/hello.py
index ed60dfa4c..a1a4a746e 100644
--- a/hello.py
+++ b/hello.py
@@ -8,6 +8,16 @@
bootstrap = Bootstrap(app)
+@app.errorhandler(404)
+def page_not_found(e):
+ return render_template('404.html'), 404
+
+
+@app.errorhandler(500)
+def internal_server_error(e):
+ return render_template('500.html'), 500
+
+
@app.route('/')
def index():
return render_template('index.html')
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 000000000..819445079
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Page Not Found{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
diff --git a/templates/500.html b/templates/500.html
new file mode 100644
index 000000000..0bb4f2727
--- /dev/null
+++ b/templates/500.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Internal Server Error{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 000000000..305d3a353
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,30 @@
+{% extends "bootstrap/base.html" %}
+
+{% block title %}Flasky{% endblock %}
+
+{% block navbar %}
+
+{% endblock %}
+
+{% block content %}
+
+ {% block page_content %}{% endblock %}
+
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
index de8b69b6e..c0dd07be2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1 +1,9 @@
-Hello World!
+{% extends "base.html" %}
+
+{% block title %}Flasky{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
diff --git a/templates/user.html b/templates/user.html
index 09b792d50..ac4befc3a 100644
--- a/templates/user.html
+++ b/templates/user.html
@@ -1,32 +1,9 @@
-{% extends "bootstrap/base.html" %}
+{% extends "base.html" %}
{% block title %}Flasky{% endblock %}
-{% block navbar %}
-
-{% endblock %}
-
-{% block content %}
-
-
+{% block page_content %}
+
{% endblock %}
From c36b3aa18164ae1d201c9fe4f0c0f7e6d31dbbf5 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:37:57 -0800
Subject: [PATCH 09/59] Chapter 3: Static files (3d)
---
static/favicon.ico | Bin 0 -> 1150 bytes
templates/base.html | 6 ++++++
2 files changed, 6 insertions(+)
create mode 100644 static/favicon.ico
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..8f674630aa3f565a08c157d4bf0919f5019269b4
GIT binary patch
literal 1150
zcmbVMO{=L<6n@nmxdW%G?m(%_#4#{2QxYkK8GeD0pCB^^WFS%oNXo##KuP&1DT+iW
z112O2r5Gp!`FQTT_i4MO;~d@fw%2<1+UwbCJ!|ha#|ilF?TzF0AI|lI<9>4-_s$RA
z#d|*dyx};0?bpwbLFc@}Cvv@Bak*S@yWKun{pTWakMf9Fuh$rl$LMrAsMqW0^?KNB
zHjgkmXV}?nhFB~Ho6QD^L;|&14XsuSkw^rwSPYd)1)WX@kH>T0htx|X9*@&FLQSL5
zU@#aUlgZFA91h|2dQmQyLF$@Lrxe3@q|<3Al}gBDGU)Ysbh};T^LZ?nOBzSi`FzId
zbh^(Owp1!XDwV=&wbHr|hXZQ08n)Xljb%M0mhfbjEbjMvC=?2qOePqOM&xoi^!t6R
zR;xRf{yb5^U=Tu~5P?7dl}d$jt5&NoVUNcn3+|_&&e3Re&&|%{Notx;E|()5
z4x`y@(w>t35>72eR2L7K;Un#UkzR
zD|n~V3A^2nXf%psGKof`LHnObB(U4<=oVjbm`o-h{K>uX`~CF%3xxvO?e@3aS$(Ng
lir#a#+f8xA|5Z+gW46_5AruPTF$Bx*C5w4x**oyR_Ad#kb)o
+
+{% endblock %}
+
{% block navbar %}
From 2d7ec2eacf44e8237259535af26b7ef79d0ed1ef Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 00:30:16 -0800
Subject: [PATCH 10/59] Chapter 3: Dates and times with Flask-Moment (3e)
---
hello.py | 6 +++++-
templates/base.html | 5 +++++
templates/index.html | 2 ++
3 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index a1a4a746e..1396f3c14 100644
--- a/hello.py
+++ b/hello.py
@@ -1,11 +1,14 @@
+from datetime import datetime
from flask import Flask, render_template
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
+from flask.ext.moment import Moment
app = Flask(__name__)
manager = Manager(app)
bootstrap = Bootstrap(app)
+moment = Moment(app)
@app.errorhandler(404)
@@ -20,7 +23,8 @@ def internal_server_error(e):
@app.route('/')
def index():
- return render_template('index.html')
+ return render_template('index.html',
+ current_time=datetime.utcnow())
@app.route('/user/')
diff --git a/templates/base.html b/templates/base.html
index 1bc8d8509..32c4376b8 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -34,3 +34,8 @@
{% block page_content %}{% endblock %}
{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{{ moment.include_moment() }}
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
index c0dd07be2..003789006 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -6,4 +6,6 @@
+
The local date and time is {{ moment(current_time).format('LLL') }}.
+
That was {{ moment(current_time).fromNow(refresh=True) }}.
{% endblock %}
From 9b853c0af157ef8f4441770aa3dee690d92fc100 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:47:29 -0800
Subject: [PATCH 11/59] Chapter 4: Web forms with Flask-WTF (4a)
---
hello.py | 25 ++++++++++++++++---------
templates/index.html | 6 +++---
templates/user.html | 9 ---------
3 files changed, 19 insertions(+), 21 deletions(-)
delete mode 100644 templates/user.html
diff --git a/hello.py b/hello.py
index 1396f3c14..711bc5a70 100644
--- a/hello.py
+++ b/hello.py
@@ -1,16 +1,24 @@
-from datetime import datetime
from flask import Flask, render_template
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
from flask.ext.moment import Moment
+from flask.ext.wtf import Form
+from wtforms import StringField, SubmitField
+from wtforms.validators import Required
app = Flask(__name__)
+app.config['SECRET_KEY'] = 'hard to guess string'
manager = Manager(app)
bootstrap = Bootstrap(app)
moment = Moment(app)
+class NameForm(Form):
+ name = StringField('What is your name?', validators=[Required()])
+ submit = SubmitField('Submit')
+
+
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
@@ -21,15 +29,14 @@ def internal_server_error(e):
return render_template('500.html'), 500
-@app.route('/')
+@app.route('/', methods=['GET', 'POST'])
def index():
- return render_template('index.html',
- current_time=datetime.utcnow())
-
-
-@app.route('/user/')
-def user(name):
- return render_template('user.html', name=name)
+ name = None
+ form = NameForm()
+ if form.validate_on_submit():
+ name = form.name.data
+ form.name.data = ''
+ return render_template('index.html', form=form, name=name)
if __name__ == '__main__':
diff --git a/templates/index.html b/templates/index.html
index 003789006..8635391a2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,11 +1,11 @@
{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky{% endblock %}
{% block page_content %}
-The local date and time is {{ moment(current_time).format('LLL') }}.
-That was {{ moment(current_time).fromNow(refresh=True) }}.
+{{ wtf.quick_form(form) }}
{% endblock %}
diff --git a/templates/user.html b/templates/user.html
deleted file mode 100644
index ac4befc3a..000000000
--- a/templates/user.html
+++ /dev/null
@@ -1,9 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Flasky{% endblock %}
-
-{% block page_content %}
-
-{% endblock %}
From f6076319e54de42c3dcd6908c987427ee182f59c Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:49:10 -0800
Subject: [PATCH 12/59] Chapter 4: Redirects and user sessions (4b)
---
hello.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/hello.py b/hello.py
index 711bc5a70..d0c7000ff 100644
--- a/hello.py
+++ b/hello.py
@@ -1,4 +1,4 @@
-from flask import Flask, render_template
+from flask import Flask, render_template, session, redirect, url_for
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
from flask.ext.moment import Moment
@@ -31,12 +31,11 @@ def internal_server_error(e):
@app.route('/', methods=['GET', 'POST'])
def index():
- name = None
form = NameForm()
if form.validate_on_submit():
- name = form.name.data
- form.name.data = ''
- return render_template('index.html', form=form, name=name)
+ session['name'] = form.name.data
+ return redirect(url_for('index'))
+ return render_template('index.html', form=form, name=session.get('name'))
if __name__ == '__main__':
From 6722abb786a3d1f4f2a10f3c4654a51b47064697 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 26 Dec 2013 23:52:33 -0800
Subject: [PATCH 13/59] Chapter 4: Message flashing (4c)
---
hello.py | 5 ++++-
templates/base.html | 7 +++++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index d0c7000ff..d72a6c33f 100644
--- a/hello.py
+++ b/hello.py
@@ -1,4 +1,4 @@
-from flask import Flask, render_template, session, redirect, url_for
+from flask import Flask, render_template, session, redirect, url_for, flash
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
from flask.ext.moment import Moment
@@ -33,6 +33,9 @@ def internal_server_error(e):
def index():
form = NameForm()
if form.validate_on_submit():
+ old_name = session.get('name')
+ if old_name is not None and old_name != form.name.data:
+ flash('Looks like you have changed your name!')
session['name'] = form.name.data
return redirect(url_for('index'))
return render_template('index.html', form=form, name=session.get('name'))
diff --git a/templates/base.html b/templates/base.html
index 32c4376b8..92ef01d69 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -31,6 +31,13 @@
{% block content %}
+ {% for message in get_flashed_messages() %}
+
+ ×
+ {{ message }}
+
+ {% endfor %}
+
{% block page_content %}{% endblock %}
{% endblock %}
From 535b16eedc69f88b519d98b650ecaf01a81a03a5 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 01:03:07 -0800
Subject: [PATCH 14/59] Chapter 5: Database models with Flask-SQLAlchemy (5a)
---
hello.py | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/hello.py b/hello.py
index d72a6c33f..bbffba17b 100644
--- a/hello.py
+++ b/hello.py
@@ -1,3 +1,4 @@
+import os
from flask import Flask, render_template, session, redirect, url_for, flash
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
@@ -5,13 +6,40 @@
from flask.ext.wtf import Form
from wtforms import StringField, SubmitField
from wtforms.validators import Required
+from flask.ext.sqlalchemy import SQLAlchemy
+
+basedir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
app.config['SECRET_KEY'] = 'hard to guess string'
+app.config['SQLALCHEMY_DATABASE_URI'] =\
+ 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
+app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
manager = Manager(app)
bootstrap = Bootstrap(app)
moment = Moment(app)
+db = SQLAlchemy(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
class NameForm(Form):
@@ -42,4 +70,5 @@ def index():
if __name__ == '__main__':
+ db.create_all()
manager.run()
From 94d87dcd8ec723df8e11b9cf18f8842394aa5e26 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 13 Feb 2014 10:11:55 -0800
Subject: [PATCH 15/59] Chapter 5: Database use in the application (5b)
---
hello.py | 16 ++++++++++------
templates/index.html | 5 +++++
2 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/hello.py b/hello.py
index bbffba17b..af6f4b55c 100644
--- a/hello.py
+++ b/hello.py
@@ -1,5 +1,5 @@
import os
-from flask import Flask, render_template, session, redirect, url_for, flash
+from flask import Flask, render_template, session, redirect, url_for
from flask.ext.script import Manager
from flask.ext.bootstrap import Bootstrap
from flask.ext.moment import Moment
@@ -61,14 +61,18 @@ def internal_server_error(e):
def index():
form = NameForm()
if form.validate_on_submit():
- old_name = session.get('name')
- if old_name is not None and old_name != form.name.data:
- flash('Looks like you have changed your name!')
+ user = User.query.filter_by(username=form.name.data).first()
+ if user is None:
+ user = User(username=form.name.data)
+ db.session.add(user)
+ session['known'] = False
+ 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'))
+ return render_template('index.html', form=form, name=session.get('name'),
+ known=session.get('known', False))
if __name__ == '__main__':
- db.create_all()
manager.run()
diff --git a/templates/index.html b/templates/index.html
index 8635391a2..b5657a7f5 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -6,6 +6,11 @@
{% block page_content %}
{{ wtf.quick_form(form) }}
{% endblock %}
From e18950defbacc7b216563170773124d4fdc56638 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 01:15:44 -0800
Subject: [PATCH 16/59] Chapter 5: Shell context (5c)
---
hello.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index af6f4b55c..b08273105 100644
--- a/hello.py
+++ b/hello.py
@@ -1,6 +1,6 @@
import os
from flask import Flask, render_template, session, redirect, url_for
-from flask.ext.script import Manager
+from flask.ext.script import Manager, Shell
from flask.ext.bootstrap import Bootstrap
from flask.ext.moment import Moment
from flask.ext.wtf import Form
@@ -47,6 +47,11 @@ class NameForm(Form):
submit = SubmitField('Submit')
+def make_shell_context():
+ return dict(app=app, db=db, User=User, Role=Role)
+manager.add_command("shell", Shell(make_context=make_shell_context))
+
+
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
From 23ee700d6691d33911abf34b72474aefaca2912f Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 01:30:09 -0800
Subject: [PATCH 17/59] Chapter 5: Database migrations with Flask-Migrate (5d)
---
hello.py | 3 +
migrations/README | 1 +
migrations/alembic.ini | 45 ++++++++++++
migrations/env.py | 73 +++++++++++++++++++
migrations/script.py.mako | 22 ++++++
.../38c4e85512a9_initial_migration.py | 41 +++++++++++
6 files changed, 185 insertions(+)
create mode 100644 migrations/README
create mode 100644 migrations/alembic.ini
create mode 100644 migrations/env.py
create mode 100644 migrations/script.py.mako
create mode 100644 migrations/versions/38c4e85512a9_initial_migration.py
diff --git a/hello.py b/hello.py
index b08273105..e5e84bc9b 100644
--- a/hello.py
+++ b/hello.py
@@ -7,6 +7,7 @@
from wtforms import StringField, SubmitField
from wtforms.validators import Required
from flask.ext.sqlalchemy import SQLAlchemy
+from flask.ext.migrate import Migrate, MigrateCommand
basedir = os.path.abspath(os.path.dirname(__file__))
@@ -20,6 +21,7 @@
bootstrap = Bootstrap(app)
moment = Moment(app)
db = SQLAlchemy(app)
+migrate = Migrate(app, db)
class Role(db.Model):
@@ -50,6 +52,7 @@ class NameForm(Form):
def make_shell_context():
return dict(app=app, db=db, User=User, Role=Role)
manager.add_command("shell", Shell(make_context=make_shell_context))
+manager.add_command('db', MigrateCommand)
@app.errorhandler(404)
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 000000000..98e4f9c44
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/migrations/alembic.ini b/migrations/alembic.ini
new file mode 100644
index 000000000..f8ed4801f
--- /dev/null
+++ b/migrations/alembic.ini
@@ -0,0 +1,45 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 000000000..70961ce2c
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,73 @@
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from logging.config import fileConfig
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app
+config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(url=url)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ engine = engine_from_config(
+ config.get_section(config.config_ini_section),
+ prefix='sqlalchemy.',
+ poolclass=pool.NullPool)
+
+ connection = engine.connect()
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata
+ )
+
+ try:
+ with context.begin_transaction():
+ context.run_migrations()
+ finally:
+ connection.close()
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
+
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 000000000..95702017e
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision}
+Create Date: ${create_date}
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/38c4e85512a9_initial_migration.py b/migrations/versions/38c4e85512a9_initial_migration.py
new file mode 100644
index 000000000..52806780d
--- /dev/null
+++ b/migrations/versions/38c4e85512a9_initial_migration.py
@@ -0,0 +1,41 @@
+"""initial migration
+
+Revision ID: 38c4e85512a9
+Revises: None
+Create Date: 2013-12-27 01:23:59.392801
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '38c4e85512a9'
+down_revision = None
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('roles',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=64), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(length=64), nullable=True),
+ sa.Column('role_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_users_username', 'users', ['username'], unique=True)
+ ### end Alembic commands ###
+
+
+def downgrade():
+ ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('ix_users_username', 'users')
+ op.drop_table('users')
+ op.drop_table('roles')
+ ### end Alembic commands ###
From f2a15d624ed36592c3f9277f5e90ff7c7e2a9abb Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 00:16:17 -0800
Subject: [PATCH 18/59] Chapter 6: Email support with Flask-Mail (6a)
---
hello.py | 21 +++++++++++++++++++++
templates/mail/new_user.html | 1 +
templates/mail/new_user.txt | 1 +
3 files changed, 23 insertions(+)
create mode 100644 templates/mail/new_user.html
create mode 100644 templates/mail/new_user.txt
diff --git a/hello.py b/hello.py
index e5e84bc9b..5f74c96b7 100644
--- a/hello.py
+++ b/hello.py
@@ -8,6 +8,7 @@
from wtforms.validators import Required
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.migrate import Migrate, MigrateCommand
+from flask.ext.mail import Mail, Message
basedir = os.path.abspath(os.path.dirname(__file__))
@@ -16,12 +17,21 @@
app.config['SQLALCHEMY_DATABASE_URI'] =\
'sqlite:///' + os.path.join(basedir, 'data.sqlite')
app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
+app.config['MAIL_SERVER'] = 'smtp.googlemail.com'
+app.config['MAIL_PORT'] = 587
+app.config['MAIL_USE_TLS'] = True
+app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
+app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
+app.config['FLASKY_MAIL_SUBJECT_PREFIX'] = '[Flasky]'
+app.config['FLASKY_MAIL_SENDER'] = 'Flasky Admin '
+app.config['FLASKY_ADMIN'] = os.environ.get('FLASKY_ADMIN')
manager = Manager(app)
bootstrap = Bootstrap(app)
moment = Moment(app)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
+mail = Mail(app)
class Role(db.Model):
@@ -44,6 +54,14 @@ def __repr__(self):
return '' % self.username
+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)
+ mail.send(msg)
+
+
class NameForm(Form):
name = StringField('What is your name?', validators=[Required()])
submit = SubmitField('Submit')
@@ -74,6 +92,9 @@ def index():
user = User(username=form.name.data)
db.session.add(user)
session['known'] = False
+ if app.config['FLASKY_ADMIN']:
+ send_email(app.config['FLASKY_ADMIN'], 'New User',
+ 'mail/new_user', user=user)
else:
session['known'] = True
session['name'] = form.name.data
diff --git a/templates/mail/new_user.html b/templates/mail/new_user.html
new file mode 100644
index 000000000..1a9c6ecd0
--- /dev/null
+++ b/templates/mail/new_user.html
@@ -0,0 +1 @@
+User {{ user.username }} has joined.
diff --git a/templates/mail/new_user.txt b/templates/mail/new_user.txt
new file mode 100644
index 000000000..e6eb5ab25
--- /dev/null
+++ b/templates/mail/new_user.txt
@@ -0,0 +1 @@
+User {{ user.username }} has joined.
From 09d4ff0f5cad0d9d7b3478c3eab761e2dd31eb37 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 00:21:31 -0800
Subject: [PATCH 19/59] Chapter 6: Asynchronous emails (6b)
---
hello.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/hello.py b/hello.py
index 5f74c96b7..dc3aac219 100644
--- a/hello.py
+++ b/hello.py
@@ -1,4 +1,5 @@
import os
+from threading import Thread
from flask import Flask, render_template, session, redirect, url_for
from flask.ext.script import Manager, Shell
from flask.ext.bootstrap import Bootstrap
@@ -54,12 +55,19 @@ 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)
- mail.send(msg)
+ thr = Thread(target=send_async_email, args=[app, msg])
+ thr.start()
+ return thr
class NameForm(Form):
From 4987f6caefddd9706e06e5fa1266528e0b80a752 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Fri, 27 Dec 2013 02:31:00 -0800
Subject: [PATCH 20/59] 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 | 27 ++++
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 | 45 +++++++
hello.py | 115 ------------------
manage.py | 28 +++++
requirements.txt | 17 +++
tests/__init__.py | 0
tests/test_basics.py | 22 ++++
20 files changed, 235 insertions(+), 117 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
delete mode 100644 hello.py
create mode 100755 manage.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..97f296d09
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,28 @@
+from flask import Flask
+from flask.ext.bootstrap import Bootstrap
+from flask.ext.mail import Mail
+from flask.ext.moment import Moment
+from flask.ext.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..c48f5eb5c
--- /dev/null
+++ b/app/email.py
@@ -0,0 +1,20 @@
+from threading import Thread
+from flask import current_app, render_template
+from flask.ext.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..d1e363053
--- /dev/null
+++ b/app/main/forms.py
@@ -0,0 +1,8 @@
+from flask.ext.wtf import Form
+from wtforms import StringField, SubmitField
+from wtforms.validators import Required
+
+
+class NameForm(Form):
+ name = StringField('What is your name?', validators=[Required()])
+ submit = SubmitField('Submit')
diff --git a/app/main/views.py b/app/main/views.py
new file mode 100644
index 000000000..071e1709d
--- /dev/null
+++ b/app/main/views.py
@@ -0,0 +1,27 @@
+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)
+ 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..f1771705b
--- /dev/null
+++ b/config.py
@@ -0,0 +1,45 @@
+import os
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+
+class Config:
+ SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
+ SQLALCHEMY_COMMIT_ON_TEARDOWN = True
+ MAIL_SERVER = 'smtp.googlemail.com'
+ MAIL_PORT = 587
+ MAIL_USE_TLS = True
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+ FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
+ FLASKY_MAIL_SENDER = 'Flasky Admin '
+ FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
+
+ @staticmethod
+ def init_app(app):
+ pass
+
+
+class DevelopmentConfig(Config):
+ DEBUG = True
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
+ 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')
+
+
+class TestingConfig(Config):
+ TESTING = True
+ SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
+ 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')
+
+
+class ProductionConfig(Config):
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
+ 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
+
+
+config = {
+ 'development': DevelopmentConfig,
+ 'testing': TestingConfig,
+ 'production': ProductionConfig,
+
+ 'default': DevelopmentConfig
+}
diff --git a/hello.py b/hello.py
deleted file mode 100644
index dc3aac219..000000000
--- a/hello.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import os
-from threading import Thread
-from flask import Flask, render_template, session, redirect, url_for
-from flask.ext.script import Manager, Shell
-from flask.ext.bootstrap import Bootstrap
-from flask.ext.moment import Moment
-from flask.ext.wtf import Form
-from wtforms import StringField, SubmitField
-from wtforms.validators import Required
-from flask.ext.sqlalchemy import SQLAlchemy
-from flask.ext.migrate import Migrate, MigrateCommand
-from flask.ext.mail import Mail, Message
-
-basedir = os.path.abspath(os.path.dirname(__file__))
-
-app = Flask(__name__)
-app.config['SECRET_KEY'] = 'hard to guess string'
-app.config['SQLALCHEMY_DATABASE_URI'] =\
- 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
-app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
-app.config['MAIL_SERVER'] = 'smtp.googlemail.com'
-app.config['MAIL_PORT'] = 587
-app.config['MAIL_USE_TLS'] = True
-app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
-app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
-app.config['FLASKY_MAIL_SUBJECT_PREFIX'] = '[Flasky]'
-app.config['FLASKY_MAIL_SENDER'] = 'Flasky Admin '
-app.config['FLASKY_ADMIN'] = os.environ.get('FLASKY_ADMIN')
-
-manager = Manager(app)
-bootstrap = Bootstrap(app)
-moment = Moment(app)
-db = SQLAlchemy(app)
-migrate = Migrate(app, db)
-mail = Mail(app)
-
-
-class Role(db.Model):
- __tablename__ = 'roles'
- id = db.Column(db.Integer, primary_key=True)
- name = db.Column(db.String(64), unique=True)
- users = db.relationship('User', backref='role', lazy='dynamic')
-
- def __repr__(self):
- return '' % self.name
-
-
-class User(db.Model):
- __tablename__ = 'users'
- id = db.Column(db.Integer, primary_key=True)
- username = db.Column(db.String(64), unique=True, index=True)
- role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
-
- def __repr__(self):
- return '' % self.username
-
-
-def send_async_email(app, msg):
- with app.app_context():
- mail.send(msg)
-
-
-def send_email(to, subject, template, **kwargs):
- msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + ' ' + subject,
- sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to])
- msg.body = render_template(template + '.txt', **kwargs)
- msg.html = render_template(template + '.html', **kwargs)
- thr = Thread(target=send_async_email, args=[app, msg])
- thr.start()
- return thr
-
-
-class NameForm(Form):
- name = StringField('What is your name?', validators=[Required()])
- submit = SubmitField('Submit')
-
-
-def make_shell_context():
- return dict(app=app, db=db, User=User, Role=Role)
-manager.add_command("shell", Shell(make_context=make_shell_context))
-manager.add_command('db', MigrateCommand)
-
-
-@app.errorhandler(404)
-def page_not_found(e):
- return render_template('404.html'), 404
-
-
-@app.errorhandler(500)
-def internal_server_error(e):
- return render_template('500.html'), 500
-
-
-@app.route('/', methods=['GET', 'POST'])
-def index():
- form = NameForm()
- if form.validate_on_submit():
- user = User.query.filter_by(username=form.name.data).first()
- if user is None:
- user = User(username=form.name.data)
- db.session.add(user)
- session['known'] = False
- if app.config['FLASKY_ADMIN']:
- send_email(app.config['FLASKY_ADMIN'], 'New User',
- 'mail/new_user', user=user)
- else:
- session['known'] = True
- session['name'] = form.name.data
- return redirect(url_for('index'))
- return render_template('index.html', form=form, name=session.get('name'),
- known=session.get('known', False))
-
-
-if __name__ == '__main__':
- manager.run()
diff --git a/manage.py b/manage.py
new file mode 100755
index 000000000..49a55ade9
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+import os
+from app import create_app, db
+from app.models import User, Role
+from flask.ext.script import Manager, Shell
+from flask.ext.migrate import Migrate, MigrateCommand
+
+app = create_app(os.getenv('FLASK_CONFIG') or 'default')
+manager = Manager(app)
+migrate = Migrate(app, db)
+
+
+def make_shell_context():
+ return dict(app=app, db=db, User=User, Role=Role)
+manager.add_command("shell", Shell(make_context=make_shell_context))
+manager.add_command('db', MigrateCommand)
+
+
+@manager.command
+def test():
+ """Run the unit tests."""
+ import unittest
+ tests = unittest.TestLoader().discover('tests')
+ unittest.TextTestRunner(verbosity=2).run(tests)
+
+
+if __name__ == '__main__':
+ manager.run()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 000000000..cfcf89084
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,17 @@
+Flask==0.10.1
+Flask-Bootstrap==3.0.3.1
+Flask-Mail==0.9.0
+Flask-Migrate==1.1.0
+Flask-Moment==0.2.1
+Flask-SQLAlchemy==1.0
+Flask-Script==0.6.6
+Flask-WTF==0.9.4
+Jinja2==2.7.1
+Mako==0.9.1
+MarkupSafe==0.18
+SQLAlchemy==0.9.9
+WTForms==1.0.5
+Werkzeug==0.10.4
+alembic==0.6.2
+blinker==1.3
+itsdangerous==0.23
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_basics.py b/tests/test_basics.py
new file mode 100644
index 000000000..0fdf4983b
--- /dev/null
+++ b/tests/test_basics.py
@@ -0,0 +1,22 @@
+import unittest
+from flask import current_app
+from app import create_app, db
+
+
+class BasicsTestCase(unittest.TestCase):
+ def setUp(self):
+ self.app = create_app('testing')
+ self.app_context = self.app.app_context()
+ self.app_context.push()
+ db.create_all()
+
+ def tearDown(self):
+ db.session.remove()
+ db.drop_all()
+ self.app_context.pop()
+
+ def test_app_exists(self):
+ self.assertFalse(current_app is None)
+
+ def test_app_is_testing(self):
+ self.assertTrue(current_app.config['TESTING'])
From 9bc4c7c5035e1f76241cdbef187a38252bd92847 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sat, 28 Dec 2013 01:05:18 -0800
Subject: [PATCH 21/59] Chapter 8: Password hashing with Werkzeug (8a)
---
app/models.py | 13 +++++++++++++
tests/test_user_model.py | 35 +++++++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+)
create mode 100644 tests/test_user_model.py
diff --git a/app/models.py b/app/models.py
index 5c885d668..c938f0aff 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,3 +1,4 @@
+from werkzeug.security import generate_password_hash, check_password_hash
from . import db
@@ -16,6 +17,18 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
+ password_hash = db.Column(db.String(128))
+
+ @property
+ def password(self):
+ raise AttributeError('password is not a readable attribute')
+
+ @password.setter
+ def password(self, password):
+ self.password_hash = generate_password_hash(password)
+
+ def verify_password(self, password):
+ return check_password_hash(self.password_hash, password)
def __repr__(self):
return '' % self.username
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
new file mode 100644
index 000000000..b705a3bcf
--- /dev/null
+++ b/tests/test_user_model.py
@@ -0,0 +1,35 @@
+import unittest
+from app import create_app, db
+from app.models import User
+
+
+class UserModelTestCase(unittest.TestCase):
+ def setUp(self):
+ self.app = create_app('testing')
+ self.app_context = self.app.app_context()
+ self.app_context.push()
+ db.create_all()
+
+ def tearDown(self):
+ db.session.remove()
+ db.drop_all()
+ self.app_context.pop()
+
+ def test_password_setter(self):
+ u = User(password='cat')
+ self.assertTrue(u.password_hash is not None)
+
+ def test_no_password_getter(self):
+ u = User(password='cat')
+ with self.assertRaises(AttributeError):
+ u.password
+
+ def test_password_verification(self):
+ u = User(password='cat')
+ self.assertTrue(u.verify_password('cat'))
+ self.assertFalse(u.verify_password('dog'))
+
+ def test_password_salts_are_random(self):
+ u = User(password='cat')
+ u2 = User(password='cat')
+ self.assertTrue(u.password_hash != u2.password_hash)
From 60320bdf54a395986e9433c89b47d6dc1849b242 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sat, 28 Dec 2013 19:36:00 -0800
Subject: [PATCH 22/59] Chapter 8: Authentication blueprint (8b)
---
app/__init__.py | 3 +++
app/auth/__init__.py | 5 +++++
app/auth/views.py | 7 +++++++
app/templates/auth/login.html | 9 +++++++++
4 files changed, 24 insertions(+)
create mode 100644 app/auth/__init__.py
create mode 100644 app/auth/views.py
create mode 100644 app/templates/auth/login.html
diff --git a/app/__init__.py b/app/__init__.py
index 97f296d09..dde92c91f 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -24,5 +24,8 @@ def create_app(config_name):
from .main import main as main_blueprint
app.register_blueprint(main_blueprint)
+ from .auth import auth as auth_blueprint
+ app.register_blueprint(auth_blueprint, url_prefix='/auth')
+
return app
diff --git a/app/auth/__init__.py b/app/auth/__init__.py
new file mode 100644
index 000000000..e54b37dc2
--- /dev/null
+++ b/app/auth/__init__.py
@@ -0,0 +1,5 @@
+from flask import Blueprint
+
+auth = Blueprint('auth', __name__)
+
+from . import views
diff --git a/app/auth/views.py b/app/auth/views.py
new file mode 100644
index 000000000..50109e0a4
--- /dev/null
+++ b/app/auth/views.py
@@ -0,0 +1,7 @@
+from flask import render_template
+from . import auth
+
+
+@auth.route('/login')
+def login():
+ return render_template('auth/login.html')
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
new file mode 100644
index 000000000..237fbf23b
--- /dev/null
+++ b/app/templates/auth/login.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Login{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
\ No newline at end of file
From d35514174293c2f074913891eaf64a3a07d23a14 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 30 Jan 2014 10:07:13 -0800
Subject: [PATCH 23/59] Chapter 8: Login and logout with Flask-Login (8c)
---
app/__init__.py | 7 ++++-
app/auth/forms.py | 11 +++++++
app/auth/views.py | 24 +++++++++++++--
app/main/views.py | 26 ++--------------
app/models.py | 11 +++++--
app/templates/auth/login.html | 6 +++-
app/templates/base.html | 7 +++++
app/templates/index.html | 9 +-----
.../versions/456a945560f6_login_support.py | 30 +++++++++++++++++++
requirements.txt | 1 +
10 files changed, 94 insertions(+), 38 deletions(-)
create mode 100644 app/auth/forms.py
create mode 100644 migrations/versions/456a945560f6_login_support.py
diff --git a/app/__init__.py b/app/__init__.py
index dde92c91f..a122c88cd 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -3,6 +3,7 @@
from flask.ext.mail import Mail
from flask.ext.moment import Moment
from flask.ext.sqlalchemy import SQLAlchemy
+from flask.ext.login import LoginManager
from config import config
bootstrap = Bootstrap()
@@ -10,6 +11,10 @@
moment = Moment()
db = SQLAlchemy()
+login_manager = LoginManager()
+login_manager.session_protection = 'strong'
+login_manager.login_view = 'auth.login'
+
def create_app(config_name):
app = Flask(__name__)
@@ -20,6 +25,7 @@ def create_app(config_name):
mail.init_app(app)
moment.init_app(app)
db.init_app(app)
+ login_manager.init_app(app)
from .main import main as main_blueprint
app.register_blueprint(main_blueprint)
@@ -28,4 +34,3 @@ def create_app(config_name):
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app
-
diff --git a/app/auth/forms.py b/app/auth/forms.py
new file mode 100644
index 000000000..e14b25d49
--- /dev/null
+++ b/app/auth/forms.py
@@ -0,0 +1,11 @@
+from flask.ext.wtf import Form
+from wtforms import StringField, PasswordField, BooleanField, SubmitField
+from wtforms.validators import Required, Length, Email
+
+
+class LoginForm(Form):
+ email = StringField('Email', validators=[Required(), Length(1, 64),
+ Email()])
+ password = PasswordField('Password', validators=[Required()])
+ remember_me = BooleanField('Keep me logged in')
+ submit = SubmitField('Log In')
diff --git a/app/auth/views.py b/app/auth/views.py
index 50109e0a4..c1be1f904 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -1,7 +1,25 @@
-from flask import render_template
+from flask import render_template, redirect, request, url_for, flash
+from flask.ext.login import login_user, logout_user, login_required
from . import auth
+from ..models import User
+from .forms import LoginForm
-@auth.route('/login')
+@auth.route('/login', methods=['GET', 'POST'])
def login():
- return render_template('auth/login.html')
+ form = LoginForm()
+ if form.validate_on_submit():
+ user = User.query.filter_by(email=form.email.data).first()
+ if user is not None and user.verify_password(form.password.data):
+ login_user(user, form.remember_me.data)
+ return redirect(request.args.get('next') or url_for('main.index'))
+ flash('Invalid username or password.')
+ return render_template('auth/login.html', form=form)
+
+
+@auth.route('/logout')
+@login_required
+def logout():
+ logout_user()
+ flash('You have been logged out.')
+ return redirect(url_for('main.index'))
diff --git a/app/main/views.py b/app/main/views.py
index 071e1709d..c8520dea6 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,27 +1,7 @@
-from flask import render_template, session, redirect, url_for, current_app
-from .. import db
-from ..models import User
-from ..email import send_email
+from flask import render_template
from . import main
-from .forms import NameForm
-@main.route('/', methods=['GET', 'POST'])
+@main.route('/')
def index():
- form = NameForm()
- if form.validate_on_submit():
- user = User.query.filter_by(username=form.name.data).first()
- if user is None:
- user = User(username=form.name.data)
- db.session.add(user)
- session['known'] = False
- if current_app.config['FLASKY_ADMIN']:
- send_email(current_app.config['FLASKY_ADMIN'], 'New User',
- 'mail/new_user', user=user)
- else:
- session['known'] = True
- session['name'] = form.name.data
- return redirect(url_for('.index'))
- return render_template('index.html',
- form=form, name=session.get('name'),
- known=session.get('known', False))
+ return render_template('index.html')
diff --git a/app/models.py b/app/models.py
index c938f0aff..3de19dd5a 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,5 +1,6 @@
from werkzeug.security import generate_password_hash, check_password_hash
-from . import db
+from flask.ext.login import UserMixin
+from . import db, login_manager
class Role(db.Model):
@@ -12,9 +13,10 @@ def __repr__(self):
return '' % self.name
-class User(db.Model):
+class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
+ email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique=True, index=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
password_hash = db.Column(db.String(128))
@@ -32,3 +34,8 @@ def verify_password(self, password):
def __repr__(self):
return '' % self.username
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ return User.query.get(int(user_id))
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
index 237fbf23b..476cff57c 100644
--- a/app/templates/auth/login.html
+++ b/app/templates/auth/login.html
@@ -1,4 +1,5 @@
{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Login{% endblock %}
@@ -6,4 +7,7 @@
-{% endblock %}
\ No newline at end of file
+
+ {{ wtf.quick_form(form) }}
+
+{% endblock %}
diff --git a/app/templates/base.html b/app/templates/base.html
index 17b38fcaf..bc2c94fe2 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -24,6 +24,13 @@
+
+ {% 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 cfcf89084..e1e565bed 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
Flask==0.10.1
Flask-Bootstrap==3.0.3.1
+Flask-Login==0.3.1
Flask-Mail==0.9.0
Flask-Migrate==1.1.0
Flask-Moment==0.2.1
From 4d16904c727e6379117883bd30156a1d442aa9f0 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Thu, 30 Jan 2014 10:18:40 -0800
Subject: [PATCH 24/59] Chapter 8: User registration (8d)
---
app/auth/forms.py | 25 ++++++++++++++++++++++++-
app/auth/views.py | 20 ++++++++++++++++++--
app/templates/auth/login.html | 2 ++
app/templates/auth/register.html | 13 +++++++++++++
4 files changed, 57 insertions(+), 3 deletions(-)
create mode 100644 app/templates/auth/register.html
diff --git a/app/auth/forms.py b/app/auth/forms.py
index e14b25d49..7db80bccc 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -1,6 +1,8 @@
from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField
-from wtforms.validators import Required, Length, Email
+from wtforms.validators import Required, Length, Email, Regexp, EqualTo
+from wtforms import ValidationError
+from ..models import User
class LoginForm(Form):
@@ -9,3 +11,24 @@ class LoginForm(Form):
password = PasswordField('Password', validators=[Required()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')
+
+
+class RegistrationForm(Form):
+ email = StringField('Email', validators=[Required(), Length(1, 64),
+ Email()])
+ username = StringField('Username', validators=[
+ Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
+ 'Usernames must have only letters, '
+ 'numbers, dots or underscores')])
+ password = PasswordField('Password', validators=[
+ Required(), EqualTo('password2', message='Passwords must match.')])
+ password2 = PasswordField('Confirm password', validators=[Required()])
+ submit = SubmitField('Register')
+
+ def validate_email(self, field):
+ if User.query.filter_by(email=field.data).first():
+ raise ValidationError('Email already registered.')
+
+ def validate_username(self, field):
+ if User.query.filter_by(username=field.data).first():
+ raise ValidationError('Username already in use.')
diff --git a/app/auth/views.py b/app/auth/views.py
index c1be1f904..75934fa50 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -1,8 +1,11 @@
from flask import render_template, redirect, request, url_for, flash
-from flask.ext.login import login_user, logout_user, login_required
+from flask.ext.login import login_user, logout_user, login_required, \
+ current_user
from . import auth
+from .. import db
from ..models import User
-from .forms import LoginForm
+from ..email import send_email
+from .forms import LoginForm, RegistrationForm
@auth.route('/login', methods=['GET', 'POST'])
@@ -23,3 +26,16 @@ def logout():
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))
+
+
+@auth.route('/register', methods=['GET', 'POST'])
+def register():
+ form = RegistrationForm()
+ if form.validate_on_submit():
+ user = User(email=form.email.data,
+ username=form.username.data,
+ password=form.password.data)
+ db.session.add(user)
+ flash('You can now login.')
+ return redirect(url_for('auth.login'))
+ return render_template('auth/register.html', form=form)
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
index 476cff57c..1e14c7f5a 100644
--- a/app/templates/auth/login.html
+++ b/app/templates/auth/login.html
@@ -9,5 +9,7 @@ Login
{% endblock %}
diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html
new file mode 100644
index 000000000..eb14df9e0
--- /dev/null
+++ b/app/templates/auth/register.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Flasky - Register{% endblock %}
+
+{% block page_content %}
+
+
+ {{ wtf.quick_form(form) }}
+
+{% endblock %}
From 2bf7718df37f076e80a01ea67772fe917d09bdc3 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Dec 2013 00:10:10 -0800
Subject: [PATCH 25/59] Chapter 8: Account confirmation (8e)
---
app/auth/views.py | 44 ++++++++++++++++++-
app/models.py | 19 ++++++++
app/templates/auth/email/confirm.html | 8 ++++
app/templates/auth/email/confirm.txt | 13 ++++++
app/templates/auth/unconfirmed.html | 20 +++++++++
.../190163627111_account_confirmation.py | 26 +++++++++++
.../versions/456a945560f6_login_support.py | 2 +-
tests/test_user_model.py | 25 +++++++++++
8 files changed, 155 insertions(+), 2 deletions(-)
create mode 100644 app/templates/auth/email/confirm.html
create mode 100644 app/templates/auth/email/confirm.txt
create mode 100644 app/templates/auth/unconfirmed.html
create mode 100644 migrations/versions/190163627111_account_confirmation.py
diff --git a/app/auth/views.py b/app/auth/views.py
index 75934fa50..f5eb0128e 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -8,6 +8,22 @@
from .forms import LoginForm, RegistrationForm
+@auth.before_app_request
+def before_request():
+ if current_user.is_authenticated \
+ and not current_user.confirmed \
+ and request.endpoint[:5] != 'auth.' \
+ and request.endpoint != 'static':
+ return redirect(url_for('auth.unconfirmed'))
+
+
+@auth.route('/unconfirmed')
+def unconfirmed():
+ if current_user.is_anonymous or current_user.confirmed:
+ return redirect(url_for('main.index'))
+ return render_template('auth/unconfirmed.html')
+
+
@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
@@ -36,6 +52,32 @@ def register():
username=form.username.data,
password=form.password.data)
db.session.add(user)
- flash('You can now login.')
+ db.session.commit()
+ token = user.generate_confirmation_token()
+ send_email(user.email, 'Confirm Your Account',
+ 'auth/email/confirm', user=user, token=token)
+ flash('A confirmation email has been sent to you by email.')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
+
+
+@auth.route('/confirm/')
+@login_required
+def confirm(token):
+ if current_user.confirmed:
+ return redirect(url_for('main.index'))
+ if current_user.confirm(token):
+ flash('You have confirmed your account. Thanks!')
+ else:
+ flash('The confirmation link is invalid or has expired.')
+ return redirect(url_for('main.index'))
+
+
+@auth.route('/confirm')
+@login_required
+def resend_confirmation():
+ token = current_user.generate_confirmation_token()
+ send_email(current_user.email, 'Confirm Your Account',
+ 'auth/email/confirm', user=current_user, token=token)
+ flash('A new confirmation email has been sent to you by email.')
+ return redirect(url_for('main.index'))
diff --git a/app/models.py b/app/models.py
index 3de19dd5a..8487178fa 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,4 +1,6 @@
from werkzeug.security import generate_password_hash, check_password_hash
+from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
+from flask import current_app
from flask.ext.login import UserMixin
from . import db, login_manager
@@ -20,6 +22,7 @@ class User(UserMixin, db.Model):
username = db.Column(db.String(64), unique=True, index=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
password_hash = db.Column(db.String(128))
+ confirmed = db.Column(db.Boolean, default=False)
@property
def password(self):
@@ -32,6 +35,22 @@ def password(self, password):
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
+ def generate_confirmation_token(self, expiration=3600):
+ s = Serializer(current_app.config['SECRET_KEY'], expiration)
+ return s.dumps({'confirm': self.id})
+
+ def confirm(self, token):
+ s = Serializer(current_app.config['SECRET_KEY'])
+ try:
+ data = s.loads(token)
+ except:
+ return False
+ if data.get('confirm') != self.id:
+ return False
+ self.confirmed = True
+ db.session.add(self)
+ return True
+
def __repr__(self):
return '' % self.username
diff --git a/app/templates/auth/email/confirm.html b/app/templates/auth/email/confirm.html
new file mode 100644
index 000000000..e15e221bf
--- /dev/null
+++ b/app/templates/auth/email/confirm.html
@@ -0,0 +1,8 @@
+Dear {{ user.username }},
+Welcome to Flasky !
+To confirm your account please click here .
+Alternatively, you can paste the following link in your browser's address bar:
+{{ url_for('auth.confirm', token=token, _external=True) }}
+Sincerely,
+The Flasky Team
+Note: replies to this email address are not monitored.
diff --git a/app/templates/auth/email/confirm.txt b/app/templates/auth/email/confirm.txt
new file mode 100644
index 000000000..16da41df1
--- /dev/null
+++ b/app/templates/auth/email/confirm.txt
@@ -0,0 +1,13 @@
+Dear {{ user.username }},
+
+Welcome to Flasky!
+
+To confirm your account please click on the following link:
+
+{{ url_for('auth.confirm', token=token, _external=True) }}
+
+Sincerely,
+
+The Flasky Team
+
+Note: replies to this email address are not monitored.
diff --git a/app/templates/auth/unconfirmed.html b/app/templates/auth/unconfirmed.html
new file mode 100644
index 000000000..cdf194f77
--- /dev/null
+++ b/app/templates/auth/unconfirmed.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+
+{% block title %}Flasky - Confirm your accont{% endblock %}
+
+{% block page_content %}
+
+{% endblock %}
diff --git a/migrations/versions/190163627111_account_confirmation.py b/migrations/versions/190163627111_account_confirmation.py
new file mode 100644
index 000000000..7b5457613
--- /dev/null
+++ b/migrations/versions/190163627111_account_confirmation.py
@@ -0,0 +1,26 @@
+"""account confirmation
+
+Revision ID: 190163627111
+Revises: 456a945560f6
+Create Date: 2013-12-29 02:58:45.577428
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '190163627111'
+down_revision = '456a945560f6'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('confirmed', sa.Boolean(), nullable=True))
+ ### end Alembic commands ###
+
+
+def downgrade():
+ ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'confirmed')
+ ### end Alembic commands ###
diff --git a/migrations/versions/456a945560f6_login_support.py b/migrations/versions/456a945560f6_login_support.py
index bb75e5097..03afc0670 100644
--- a/migrations/versions/456a945560f6_login_support.py
+++ b/migrations/versions/456a945560f6_login_support.py
@@ -27,4 +27,4 @@ def downgrade():
op.drop_index('ix_users_email', 'users')
op.drop_column('users', 'password_hash')
op.drop_column('users', 'email')
- ### end Alembic commands ###
\ No newline at end of file
+ ### end Alembic commands ###
diff --git a/tests/test_user_model.py b/tests/test_user_model.py
index b705a3bcf..4c8765774 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -1,4 +1,5 @@
import unittest
+import time
from app import create_app, db
from app.models import User
@@ -33,3 +34,27 @@ def test_password_salts_are_random(self):
u = User(password='cat')
u2 = User(password='cat')
self.assertTrue(u.password_hash != u2.password_hash)
+
+ def test_valid_confirmation_token(self):
+ u = User(password='cat')
+ db.session.add(u)
+ db.session.commit()
+ token = u.generate_confirmation_token()
+ self.assertTrue(u.confirm(token))
+
+ def test_invalid_confirmation_token(self):
+ u1 = User(password='cat')
+ u2 = User(password='dog')
+ db.session.add(u1)
+ db.session.add(u2)
+ db.session.commit()
+ token = u1.generate_confirmation_token()
+ self.assertFalse(u2.confirm(token))
+
+ def test_expired_confirmation_token(self):
+ u = User(password='cat')
+ db.session.add(u)
+ db.session.commit()
+ token = u.generate_confirmation_token(1)
+ time.sleep(2)
+ self.assertFalse(u.confirm(token))
From 09143fae491219d66e55f63a9896fd78b01826e1 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Dec 2013 12:20:24 -0800
Subject: [PATCH 26/59] Chapter 8: Password updates (8f)
---
app/auth/forms.py | 8 ++++++++
app/auth/views.py | 17 ++++++++++++++++-
app/templates/auth/change_password.html | 13 +++++++++++++
app/templates/base.html | 8 +++++++-
4 files changed, 44 insertions(+), 2 deletions(-)
create mode 100644 app/templates/auth/change_password.html
diff --git a/app/auth/forms.py b/app/auth/forms.py
index 7db80bccc..dbf125d7a 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -32,3 +32,11 @@ def validate_email(self, field):
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')
+
+
+class ChangePasswordForm(Form):
+ old_password = PasswordField('Old password', validators=[Required()])
+ password = PasswordField('New password', validators=[
+ Required(), EqualTo('password2', message='Passwords must match')])
+ password2 = PasswordField('Confirm new password', validators=[Required()])
+ submit = SubmitField('Update Password')
diff --git a/app/auth/views.py b/app/auth/views.py
index f5eb0128e..758f126a6 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -5,7 +5,7 @@
from .. import db
from ..models import User
from ..email import send_email
-from .forms import LoginForm, RegistrationForm
+from .forms import LoginForm, RegistrationForm, ChangePasswordForm
@auth.before_app_request
@@ -81,3 +81,18 @@ def resend_confirmation():
'auth/email/confirm', user=current_user, token=token)
flash('A new confirmation email has been sent to you by email.')
return redirect(url_for('main.index'))
+
+
+@auth.route('/change-password', methods=['GET', 'POST'])
+@login_required
+def change_password():
+ form = ChangePasswordForm()
+ if form.validate_on_submit():
+ if current_user.verify_password(form.old_password.data):
+ current_user.password = form.password.data
+ db.session.add(current_user)
+ flash('Your password has been updated.')
+ return redirect(url_for('main.index'))
+ else:
+ flash('Invalid password.')
+ return render_template("auth/change_password.html", form=form)
diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html
new file mode 100644
index 000000000..374d86206
--- /dev/null
+++ b/app/templates/auth/change_password.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}Flasky - Change Password{% endblock %}
+
+{% block page_content %}
+
+
+ {{ wtf.quick_form(form) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/base.html b/app/templates/base.html
index bc2c94fe2..cd96c3338 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -26,7 +26,13 @@
{% if current_user.is_authenticated %}
- Log Out
+
+ Account
+
+
{% else %}
Log In
{% endif %}
From 1db4cbde3ea558899c289abac6ec65506f5bf9a4 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Dec 2013 13:04:33 -0800
Subject: [PATCH 27/59] Chapter 8: Password resets (8g)
---
app/auth/forms.py | 19 ++++++++++
app/auth/views.py | 39 +++++++++++++++++++-
app/models.py | 16 ++++++++
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 | 18 +++++++++
8 files changed, 126 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 dbf125d7a..c2560e31b 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -40,3 +40,22 @@ class ChangePasswordForm(Form):
Required(), EqualTo('password2', message='Passwords must match')])
password2 = PasswordField('Confirm new password', validators=[Required()])
submit = SubmitField('Update Password')
+
+
+class PasswordResetRequestForm(Form):
+ email = StringField('Email', validators=[Required(), Length(1, 64),
+ Email()])
+ submit = SubmitField('Reset Password')
+
+
+class PasswordResetForm(Form):
+ email = StringField('Email', validators=[Required(), Length(1, 64),
+ Email()])
+ password = PasswordField('New Password', validators=[
+ Required(), EqualTo('password2', message='Passwords must match')])
+ password2 = PasswordField('Confirm password', validators=[Required()])
+ submit = SubmitField('Reset Password')
+
+ def validate_email(self, field):
+ if User.query.filter_by(email=field.data).first() is None:
+ raise ValidationError('Unknown email address.')
diff --git a/app/auth/views.py b/app/auth/views.py
index 758f126a6..6f403f1e4 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
@@ -96,3 +97,39 @@ 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).first()
+ if user:
+ token = user.generate_reset_token()
+ send_email(user.email, 'Reset Your Password',
+ 'auth/email/reset_password',
+ user=user, token=token,
+ next=request.args.get('next'))
+ 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():
+ user = User.query.filter_by(email=form.email.data).first()
+ if user is None:
+ return redirect(url_for('main.index'))
+ if user.reset_password(token, form.password.data):
+ 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 8487178fa..1aa42535e 100644
--- a/app/models.py
+++ b/app/models.py
@@ -51,6 +51,22 @@ 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})
+
+ def reset_password(self, token, new_password):
+ s = Serializer(current_app.config['SECRET_KEY'])
+ try:
+ data = s.loads(token)
+ except:
+ return False
+ if data.get('reset') != self.id:
+ return False
+ self.password = new_password
+ db.session.add(self)
+ 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..def9e0e18 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -58,3 +58,21 @@ 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(u.reset_password(token, 'dog'))
+ self.assertTrue(u.verify_password('dog'))
+
+ def test_invalid_reset_token(self):
+ u1 = User(password='cat')
+ u2 = User(password='dog')
+ db.session.add(u1)
+ db.session.add(u2)
+ db.session.commit()
+ token = u1.generate_reset_token()
+ self.assertFalse(u2.reset_password(token, 'horse'))
+ self.assertTrue(u2.verify_password('dog'))
From 8daf59928986b733397247b9b067ca62e489688e Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Dec 2013 12:07:29 -0800
Subject: [PATCH 28/59] Chapter 8: Email address changes (8h)
---
app/auth/forms.py | 11 ++++++++
app/auth/views.py | 31 +++++++++++++++++++++-
app/models.py | 21 +++++++++++++++
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, 122 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 c2560e31b..e8853b651 100644
--- a/app/auth/forms.py
+++ b/app/auth/forms.py
@@ -59,3 +59,14 @@ class PasswordResetForm(Form):
def validate_email(self, field):
if User.query.filter_by(email=field.data).first() is None:
raise ValidationError('Unknown email address.')
+
+
+class ChangeEmailForm(Form):
+ email = StringField('New Email', validators=[Required(), Length(1, 64),
+ Email()])
+ password = PasswordField('Password', validators=[Required()])
+ submit = SubmitField('Update Email Address')
+
+ def validate_email(self, field):
+ if User.query.filter_by(email=field.data).first():
+ raise ValidationError('Email already registered.')
diff --git a/app/auth/views.py b/app/auth/views.py
index 6f403f1e4..696437bc9 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
@@ -133,3 +133,32 @@ 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
+ 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):
+ 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 1aa42535e..33eae2a3d 100644
--- a/app/models.py
+++ b/app/models.py
@@ -67,6 +67,27 @@ def reset_password(self, token, new_password):
db.session.add(self)
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})
+
+ def change_email(self, token):
+ s = Serializer(current_app.config['SECRET_KEY'])
+ try:
+ data = s.loads(token)
+ 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 def9e0e18..1887dd4cc 100644
--- a/tests/test_user_model.py
+++ b/tests/test_user_model.py
@@ -76,3 +76,31 @@ def test_invalid_reset_token(self):
token = u1.generate_reset_token()
self.assertFalse(u2.reset_password(token, 'horse'))
self.assertTrue(u2.verify_password('dog'))
+
+ 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 1ee657728ace01abbcf1560bb7de97444def2ca8 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Sun, 29 Dec 2013 23:21:22 -0800
Subject: [PATCH 29/59] Chapter 9: User roles and permissions (9a)
---
app/decorators.py | 19 ++++++
app/main/__init__.py | 6 ++
app/main/errors.py | 5 ++
app/models.py | 58 ++++++++++++++++++-
app/templates/403.html | 9 +++
manage.py | 4 +-
.../versions/56ed7d33de8d_user_roles.py | 30 ++++++++++
tests/test_user_model.py | 12 +++-
8 files changed, 139 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..2707dc5e2
--- /dev/null
+++ b/app/decorators.py
@@ -0,0 +1,19 @@
+from functools import wraps
+from flask import abort
+from flask.ext.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.ADMINISTER)(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 33eae2a3d..73811f4a3 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,16 +1,47 @@
from werkzeug.security import generate_password_hash, check_password_hash
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app
-from flask.ext.login import UserMixin
+from flask.ext.login import UserMixin, AnonymousUserMixin
from . import db, login_manager
+class Permission:
+ FOLLOW = 0x01
+ COMMENT = 0x02
+ WRITE_ARTICLES = 0x04
+ MODERATE_COMMENTS = 0x08
+ ADMINISTER = 0x80
+
+
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')
+ @staticmethod
+ def insert_roles():
+ roles = {
+ 'User': (Permission.FOLLOW |
+ Permission.COMMENT |
+ Permission.WRITE_ARTICLES, True),
+ 'Moderator': (Permission.FOLLOW |
+ Permission.COMMENT |
+ Permission.WRITE_ARTICLES |
+ Permission.MODERATE_COMMENTS, False),
+ 'Administrator': (0xff, False)
+ }
+ for r in roles:
+ role = Role.query.filter_by(name=r).first()
+ if role is None:
+ role = Role(name=r)
+ role.permissions = roles[r][0]
+ role.default = roles[r][1]
+ db.session.add(role)
+ db.session.commit()
+
def __repr__(self):
return '' % self.name
@@ -24,6 +55,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(permissions=0xff).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')
@@ -88,10 +127,27 @@ def change_email(self, token):
db.session.add(self)
return True
+ def can(self, permissions):
+ return self.role is not None and \
+ (self.role.permissions & permissions) == permissions
+
+ def is_administrator(self):
+ return self.can(Permission.ADMINISTER)
+
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/manage.py b/manage.py
index 49a55ade9..30e9a36a2 100755
--- a/manage.py
+++ b/manage.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python
import os
from app import create_app, db
-from app.models import User, Role
+from app.models import User, Role, Permission
from flask.ext.script import Manager, Shell
from flask.ext.migrate import Migrate, MigrateCommand
@@ -11,7 +11,7 @@
def make_shell_context():
- return dict(app=app, db=db, User=User, Role=Role)
+ return dict(app=app, db=db, User=User, Role=Role, Permission=Permission)
manager.add_command("shell", Shell(make_context=make_shell_context))
manager.add_command('db', MigrateCommand)
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 1887dd4cc..0fb2d4222 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()
@@ -104,3 +105,12 @@ 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_roles_and_permissions(self):
+ u = User(email='john@example.com', password='cat')
+ self.assertTrue(u.can(Permission.WRITE_ARTICLES))
+ self.assertFalse(u.can(Permission.MODERATE_COMMENTS))
+
+ def test_anonymous_user(self):
+ u = AnonymousUser()
+ self.assertFalse(u.can(Permission.FOLLOW))
From 04e3770e2e3719101f90623b1df418b84c66487c Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Mon, 30 Dec 2013 00:11:00 -0800
Subject: [PATCH 30/59] Chapter 10: User profiles (10a)
---
app/auth/views.py | 11 +++---
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, 102 insertions(+), 6 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 696437bc9..a72684a00 100644
--- a/app/auth/views.py
+++ b/app/auth/views.py
@@ -11,11 +11,12 @@
@auth.before_app_request
def before_request():
- if current_user.is_authenticated \
- and not current_user.confirmed \
- and request.endpoint[:5] != 'auth.' \
- and request.endpoint != 'static':
- return redirect(url_for('auth.unconfirmed'))
+ if current_user.is_authenticated:
+ current_user.ping()
+ if not current_user.confirmed \
+ and request.endpoint[:5] != '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 73811f4a3..064ce6467 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
@@ -54,6 +55,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)
@@ -134,6 +140,10 @@ def can(self, permissions):
def is_administrator(self):
return self.can(Permission.ADMINISTER)
+ 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..49ec5890e
--- /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 0fb2d4222..0bf0c9f18 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
@@ -114,3 +115,21 @@ def test_roles_and_permissions(self):
def test_anonymous_user(self):
u = AnonymousUser()
self.assertFalse(u.can(Permission.FOLLOW))
+
+ 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 5b7413da7d2ef19e34814190d4b891efee4954a9 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Mon, 30 Dec 2013 19:18:14 -0800
Subject: [PATCH 31/59] Chapter 10: Profiles editor (10b)
---
app/main/forms.py | 45 ++++++++++++++++++++++++++--
app/main/views.py | 52 +++++++++++++++++++++++++++++++--
app/templates/edit_profile.html | 13 +++++++++
app/templates/user.html | 8 +++++
4 files changed, 114 insertions(+), 4 deletions(-)
create mode 100644 app/templates/edit_profile.html
diff --git a/app/main/forms.py b/app/main/forms.py
index d1e363053..feee810ab 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -1,8 +1,49 @@
from flask.ext.wtf import Form
-from wtforms import StringField, SubmitField
-from wtforms.validators import Required
+from wtforms import StringField, TextAreaField, BooleanField, SelectField,\
+ SubmitField
+from wtforms.validators import Required, Length, Email, Regexp
+from wtforms import ValidationError
+from ..models import Role, User
class NameForm(Form):
name = StringField('What is your name?', validators=[Required()])
submit = SubmitField('Submit')
+
+
+class EditProfileForm(Form):
+ 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(Form):
+ email = StringField('Email', validators=[Required(), Length(1, 64),
+ Email()])
+ username = StringField('Username', validators=[
+ Required(), Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
+ 'Usernames must have only letters, '
+ 'numbers, dots or underscores')])
+ 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..834c72343 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.ext.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,47 @@ 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)
+ 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)
+ 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 49ec5890e..063459132 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 639eb5ff642b63c2ddb7a647290839393ff2d00a Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Wed, 26 Feb 2014 22:40:11 -0800
Subject: [PATCH 32/59] Chapter 10: User avatars (10c)
---
app/models.py | 12 +++++++++++-
app/static/styles.css | 8 ++++++++
app/templates/base.html | 6 +++++-
app/templates/user.html | 41 +++++++++++++++++++++-------------------
tests/test_user_model.py | 17 +++++++++++++++++
5 files changed, 63 insertions(+), 21 deletions(-)
create mode 100644 app/static/styles.css
diff --git a/app/models.py b/app/models.py
index 064ce6467..f0fcbc870 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.ext.login import UserMixin, AnonymousUserMixin
from . import db, login_manager
@@ -144,6 +145,15 @@ def ping(self):
self.last_seen = datetime.utcnow()
db.session.add(self)
+ def gravatar(self, size=100, default='identicon', rating='g'):
+ if request.is_secure:
+ url = 'https://secure.gravatar.com/avatar'
+ else:
+ url = 'http://www.gravatar.com/avatar'
+ hash = hashlib.md5(self.email.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 09e7af02a26f5e8dab967bd7b61898b6021e2782 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Tue, 4 Feb 2014 22:41:58 -0800
Subject: [PATCH 36/59] Chapter 11: Generate fake users and posts (11c)
---
app/models.py | 37 +++++++++++++++++++++
requirements.txt => requirements/common.txt | 0
requirements/dev.txt | 2 ++
requirements/prod.txt | 1 +
4 files changed, 40 insertions(+)
rename requirements.txt => requirements/common.txt (100%)
create mode 100644 requirements/dev.txt
create mode 100644 requirements/prod.txt
diff --git a/app/models.py b/app/models.py
index 25205bad6..794989a3c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -64,6 +64,28 @@ class User(UserMixin, db.Model):
avatar_hash = db.Column(db.String(32))
posts = db.relationship('Post', backref='author', lazy='dynamic')
+ @staticmethod
+ def generate_fake(count=100):
+ from sqlalchemy.exc import IntegrityError
+ from random import seed
+ import forgery_py
+
+ seed()
+ for i in range(count):
+ u = User(email=forgery_py.internet.email_address(),
+ username=forgery_py.internet.user_name(True),
+ password=forgery_py.lorem_ipsum.word(),
+ confirmed=True,
+ name=forgery_py.name.full_name(),
+ location=forgery_py.address.city(),
+ about_me=forgery_py.lorem_ipsum.sentence(),
+ member_since=forgery_py.date.date(True))
+ db.session.add(u)
+ try:
+ db.session.commit()
+ except IntegrityError:
+ db.session.rollback()
+
def __init__(self, **kwargs):
super(User, self).__init__(**kwargs)
if self.role is None:
@@ -187,3 +209,18 @@ class Post(db.Model):
body = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
+
+ @staticmethod
+ def generate_fake(count=100):
+ from random import seed, randint
+ import forgery_py
+
+ seed()
+ user_count = User.query.count()
+ for i in range(count):
+ u = User.query.offset(randint(0, user_count - 1)).first()
+ p = Post(body=forgery_py.lorem_ipsum.sentences(randint(1, 5)),
+ timestamp=forgery_py.date.date(True),
+ 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..3af9b8ca7
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,2 @@
+-r common.txt
+ForgeryPy==0.1
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 bd5a0e9f66fdb8f8bf5d135433ea97321e263998 Mon Sep 17 00:00:00 2001
From: Miguel Grinberg
Date: Tue, 31 Dec 2013 01:57:14 -0800
Subject: [PATCH 37/59] 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 4c6c7b08d..044e97ced 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.ext.login import login_required, current_user
from . import main
from .forms import EditProfileForm, EditProfileAdminForm, PostForm
@@ -16,15 +17,25 @@ def index():
author=current_user._get_current_object())
db.session.add(post)
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 64ea2ab49..9154ae153 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 @@