permissions
may be the right approach# app/models.py
#...
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer)
users = db.relationship('User', backref='role', lazy='dynamic')
def __repr__(self):
return '<Role %r>' % self.name
#...
True
for only one role and False
for all the othersTask name | Bit value | Description |
---|---|---|
Follow users | 0b00000001 (0x01) | Follow other users |
Comment on posts made by others | 0b00000010 (0x02) | Comment on articles written by others |
Write articles | 0b00000100 (0x04) | Write original articles |
Moderate comments made by others | 0b00001000 (0x08) | Suppress offensive comments made by others |
Administration access | 0b10000000 (0x80) | Administrative access to the site |
# app/models.py
#...
class Permission:
FOLLOW = 0x01
COMMENT = 0x02
WRITE_ARTICLES = 0x04
MODERATE_COMMENTS = 0x08
ADMINISTER = 0x80
#...
User role | Permissions | Description |
---|---|---|
Anonymous | 0b00000000 (0x00) | User who is not logged in. Read-only access to the application. |
User | 0b00000111 (0x07) | Basic permissions to write articles and comments and to follow other users. This is the default for new users. |
Moderator | 0b00001111 (0x0f) | Adds permission to suppress comments deemed offensive or inappropriate. |
Administrator | 0b11111111 (0xff) | Full access, which includes permission to change the roles of other users. |
insert_roles()
to the Role
class to create roles in the database# app/models.py
#...
class Role(db.Model):
#...
@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()
roles
dictionary inside the insert_roles()
and rerun the function¶(venv) $ python manage.py db upgrade
(venv) $ python manage.py shell
>>> Role.insert_roles()
>>> Role.query.all()
[<Role 'Administrator'>, <Role 'User'>, <Role 'Moderator'>]
FLASKY_ADMIN
configuration variable.administrator
or default
roles depending on the email address.# app/models.py
#...
class User(UserMixin, db.Model):
#...
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()
else:
self.role = Role.query.filter_by(default=True).first()
#...
# app/models.py
# ...
class User(UserMixin, db.Model):
# ...
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)
current_user.can()
and current_user.is_administrator()
without having to check whether the user is logged in first.AnonymousUserMixin
class and is registered to current_user
when the user is not logged in.AnonymousUser
class that implements the can()
and is_administrator()
methods.# app/models.py
#...
from flask_login import UserMixin, AnonymousUserMixin
#...
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
def is_administrator(self):
return False
login_manager.anonymous_user = AnonymousUser
Implement two customized decorators to check user permissions
Note: If you are unfamiliar with decorator, please refer to http://tw.pyladies.com/~maomao/6_effective_python.slides.html#/1.
# app/decorators.py
from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.can(permission):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
return permission_required(Permission.ADMINISTER)(f)
<!-- app/templates/403.html -->
{% extends "base.html" %}
{% block title %}Flasky - Forbidden{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Forbidden</h1>
</div>
{% endblock %}
# app/main/views.py
from flask import render_template
from flask_login import login_required
from . import main
from ..decorators import admin_required, permission_required
from ..models import Permission
@main.route('/')
def index():
return render_template('index.html')
@main.route('/admin')
@login_required
@admin_required
def for_admins_only():
return "For administrators!"
@main.route('/moderator')
@login_required
@permission_required(Permission.MODERATE_COMMENTS)
def for_moderators_only():
return "For comment moderators!"
user
, moderator
and administrator
in the database¶role
to your old data¶(venv) $ python manage.py shell
>>> users = User.query.all()
>>> for u in users:
... u.role = Role.query.filter_by(default=True).first()
... db.session.add(u)
>>> db.session.commit()
(venv) $ python manage.py shell
>>> u = User(username="newuser", email="newuser@pyladies.com", password="123456", confirmed=True)
>>> db.session.add(u)
>>> db.session.commit()
moderator
role in the database¶>>> u = User(username="alicia", email="alicia@pyladies.com", password="123456", confirmed=True, role=Role.query.filter_by(name="Moderator").first())
>>> db.session.add(u)
>>> db.session.commit()
administrator
's email as an environment variable.¶Mac
(venv) $ export FLASKY_ADMIN="admin@pyladies.com"
Microsoft
(venv) $ set FLASKY_ADMIN="admin@pyladies.com"
administrator
role in the database¶(venv) $ python manage.py shell
>>> u = User(username="admin", email="admin@pyladies.com", password="123456", confirmed=True)
>>> db.session.add(u)
>>> db.session.commit()
(venv) $ python manage.py runserver
Permission
globally available to all templates.¶render_template()
call, add the Permission
class to the template context by a context processor.# app/main/__init__.py
#...
from ..models import Permission
#...
@main.app_context_processor
def inject_permissions():
return dict(Permission=Permission)
# tests/test_user_model.py
#...
from app.models import User, Role, Permission, AnonymousUser
class UserModelTestCase(unittest.TestCase):
#...
def test_roles_and_permissions(self):
Role.insert_roles()
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))
$
git clone https://github.com/win911/flask_class.git$
git checkout 9a$
python manage.py db upgrade# app/models.py
from datetime import datetime
#...
class User(UserMixin, db.Model):
#...
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)
#...
db.string
: a string with a maximum length (optional in some databases, e.g. PostgreSQL)db.Text
: some longer unicode text, does not need a maximum lengthdatetime.utcnow
is missing the ()
at the end¶default
argument to db.Column()
can take a function as a default valuelast_seen
field is initialized to the current time upon creation.# app/models.py
from datetime import datetime
#...
class User(UserMixin, db.Model):
#...
def ping(self):
self.last_seen = datetime.utcnow()
db.session.add(self)
ping()
method must be called each time a request from the user is received.before_app_request
handler in the auth
blueprint runs before every request.last_seen
field.# app/auth/views.py
#...
@auth.before_app_request
def before_request():
if current_user.is_authenticated:
current_user.ping()
if not current_user.confirmed \
and request.endpoint \
and request.endpoint[:5] != 'auth.' \
and request.endpoint != 'static':
return redirect(url_for('auth.unconfirmed'))
#...
(venv) $ python manage.py db upgrade
# app/main/views.py
from flask import render_template, abort
#...
#...
from ..models import Permission, User
#...
@main.route('/user/<username>')
def user_profile(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
return render_template('user.html', user=user)
<!-- app/templates/user.html -->
{% extends "base.html" %}
{% block title %}Flasky - {{ user.username }}{% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>{{ user.username }}</h1>
{% if user.name or user.location %}
<p>
{% if user.name %}{{ user.name }}{% endif %}
{% if user.location %}
From <a href="http://maps.google.com/?q={{ user.location }}">
{{ user.location }}
</a>
{% endif %}
</p>
{% endif %}
{% if current_user.is_administrator() %}
<p><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
{% endif %}
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
<p>
Member since {{ moment(user.member_since).format('L') }}.
Last seen {{ moment(user.last_seen).fromNow() }}.
</p>
</div>
{% endblock %}
base.html
.is_authenticated
) is necessary because the navigation bar is also rendered for non-authenticated users.<!-- app/templates/base.html -->
...
{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
<div class="container">
...
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
...
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('main.user_profile', username=current_user.username) }}">Profile</a></li>
{% endif %}
</ul>
...
location
and about_me
information¶(venv) $ python manage.py shell
>>> u = User(username='newuser2', email='newuser2@pyladies.com', password='123456', confirmed=True, name='New User 2', location='Taipei', about_me='member of pyladies')
>>> db.session.add(u)
>>> db.session.commit()
$
git clone https://github.com/win911/flask_class.git$
git checkout 10a$
python manage.py db upgrade# app/main/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import Required, Length
# ...
class EditProfileForm(FlaskForm):
name = StringField('Real name', validators=[Length(0, 64)])
location = StringField('Location', validators=[Length(0, 64)])
about_me = TextAreaField('About me')
submit = SubmitField('Submit')
# app/main/views.py
from flask import render_template, abort, flash, redirect, url_for
from flask_login import login_required, current_user
#...
from .forms import EditProfileForm
from .. import db
#...
@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_profile', 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)
<!-- app/templates/edit_profile.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Profile{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Your Profile</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
<!-- app/templates/user.html -->
...
{% block page_content %}
...
<p>
Member since {{ moment(user.member_since).format('L') }}.
Last seen {{ moment(user.last_seen).fromNow() }}.
</p>
<p>
{% if user == current_user %}
<a class="btn btn-default" href="{{ url_for('.edit_profile') }}">
Edit Profile
</a>
{% endif %}
</p>
</div>
{% endblock %}
$
git clone https://github.com/win911/flask_class.git$
git checkout 10b# app/main/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, SelectField
from wtforms.validators import Required, Length, Email, Regexp
from wtforms import ValidationError
from ..models import Role, User
#...
class EditProfileAdminForm(FlaskForm):
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')
<select>
HTML form control, which implements a dropdown listchoices
attributecoerce=int
argument is added to the SelectField constructor¶# app/main/forms.py
#...
class EditProfileAdminForm(FlaskForm):
#...
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.')
email
and username
fields requires some careful handling¶id
, so Flask-SQLALchemy's get_or_404()
convenience function is used.id
is invalid, the request will return a code 404
error.role
field, the role_id
is used because the choices
attribute uses the numeric identifiers.# app/main/views.py
#...
from .forms import EditProfileForm, EditProfileAdminForm
from ..models import Permission, User, Role
#...
@main.route('/edit-profile/<int:id>', 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_profile', 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)
<!-- app/templates/user.html -->
...
{% block page_content %}
...
<p>
{% if user == current_user %}
<a class="btn btn-default" href="{{ url_for('.edit_profile') }}">
Edit Profile
</a>
{% endif %}
{% if current_user.is_administrator() %}
<a class="btn btn-danger" href="{{ url_for('.edit_profile_admin', id=user.id) }}">
Edit Profile [Admin]
</a>
{% endif %}
</p>
</div>
{% endblock %}
$
git clone https://github.com/win911/flask_class.git$
git checkout 10cEdit User
link for admin¶/edit_profile/choose-user
¶# app/main/views.py
# ...
@main.route('/edit-profile/choose-user', methods=['GET', 'POST'])
@login_required
@admin_required
def choose_user():
users = User.query.all()
return render_template('choose_user.html', users=users)
choose_user.html
¶<!-- app/templates/choose_user.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Choose User{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Choose which user to edit</h1>
</div>
<ul class="posts">
{% for user in users %}
<li>
<a href="{{ url_for('.user_profile', username=user.username) }}">{{ user.username }}</a>
{% endfor %}
</ul>
{% endblock %}
Edit User
¶<!-- app/templates/base.html -->
...
{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
...
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('main.user_profile', username=current_user.username) }}">Profile</a></li>
{% endif %}
{% if current_user.is_administrator() %}
<li><a href="{{ url_for('main.choose_user') }}">Edit User</a></li>
{% endif %}
</ul>
...
edit-profile.html
to customize heading¶<!-- app/templates/edit_profile.html -->
...
{% block page_content %}
<div class="page-header">
{% if user == current_user %}
<h1>Edit Your Profile</h1>
{% else %}
<h1>Edit {{ user.username }}'s Profile</h1>
{% endif %}
</div>
...
# app/main/views.py
# ...
@main.route('/edit-profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
# ...
return render_template('edit_profile.html', form=form, user=current_user)
@main.route('/edit-profile/<int:id>', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_profile_admin(id):
# ...
return render_template('edit_profile.html', form=form, user=user)
$
git clone https://github.com/win911/flask_class.git$
git checkout 10d(venv) $ python
>>> import hashlib
>>> hashlib.md5('john@example.com'.encode('utf-8')).hexdigest()
'd4c74594d841139328695756648b6bd6'
http:// www.gravatar.com/avatar/
or https://secure.gravatar.com/avatar/
.john@example.com
: http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6Argument name | Description |
---|---|
s | Image size, in pixels. |
r | Image rating. Options are "g", "pg", "r", and "x". |
d | The default image generator for users who have no avatars registered with the Gravatar service. Options are "404"to return a 404 error,a URL that points to a default image, or one of the following image generators: "mm", "identicon", "monsterid", "wavatar", "retro", or "blank". |
fd | Force the use of default avatars. |
User
model¶# app/models.py
#...
import hashlib
from flask import current_app, request
#...
class User(UserMixin, db.Model):
#...
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_value = hashlib.md5(self.email.encode('utf-8')).hexdigest()
return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
url=url, hash=hash_value, size=size, default=default, rating=rating)
(venv) $ python manage.py shell
>>> u = User(email='john@example.com')
>>> u.gravatar()
'http://www.gravatar.com/avatar/d4c74594d84113932869575bd6?s=100&d=identicon&r=g'
>>> u.gravatar(size=256)
'http://www.gravatar.com/avatar/d4c74594d84113932869575bd6?s=256&d=identicon&r=g'
<!-- app/tempaltes/user.html -->
...
{% block page_content %}
<div class="page-header">
<img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}">
<div class="profile-header">
<h1>{{ user.username }}</h1>
...
</div>
</div>
{% endblock %}
<!-- app/templates/base.html -->
...
{% block head %}
{{ super() }}
...
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
{% endblock %}
{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
...
<div class="navbar-collapse collapse">
...
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<img src="{{ current_user.gravatar(size=18) }}">
Account <b class="caret"></b>
</a>
...
app/static/styles.css
¶/* app/static/styles.css */
.profile-thumbnail {
position: absolute;
}
.profile-header {
min-height: 260px;
margin-left: 280px;
}
# app/models.py
#...
class User(UserMixin, db.Model):
#...
avatar_hash = db.Column(db.String(32))
def __init__(self, **kwargs):
#...
if self.email is not None and self.avatar_hash is None:
self.avatar_hash = self.gravatar_hash()
def change_email(self, token):
#...
self.email = new_email
self.avatar_hash = self.gravatar_hash()
db.session.add(self)
return True
#...
# app/models.py
#...
class User(UserMixin, db.Model):
#...
def gravatar_hash(self):
return hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
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_value = self.avatar_hash or self.gravatar_hash()
return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
url=url, hash=hash_value, size=size, default=default, rating=rating)
(venv) $ python manage.py db upgrade
$
git clone https://github.com/win911/flask_class.git$
git checkout 10e$
python manage.py db upgradePost
model to represent blog posts¶Post
model has a one-to-many relationship from the User
model.body
field is defined with type db.Text
, which has no limitation on the length.# app/models.py
#...
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
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'))
class User(UserMixin, db.Model):
#...
posts = db.relationship('Post', backref='author', lazy='dynamic')
(venv) $ python manage.py db upgrade
# app/main/forms.py
#...
class PostForm(FlaskForm):
body = TextAreaField("What's on your mind?", validators=[Required()])
submit = SubmitField('Submit')
index()
view function handles the blog post form and passes the list of old blog posts to the template.current_user
variable from Flask-Login is a wrapper that contains the acutal user object inside._get_current_object()
.# app/main/views.py
from .forms import EditProfileForm, EditProfileAdminForm, PostForm
from ..models import Permission, Role, User, Post
#...
@main.route('/', methods=['GET', 'POST'])
def index():
form = PostForm()
if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
post = Post(body=form.body.data, 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)
index.html
template.User.can()
is used to skip the blog post form for users who do not have the WRITE_ARTICLES
permission.styles.css
file in the app/static
folder.<!-- app/tempaltes/index.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1>
</div>
<div>
{% if current_user.can(Permission.WRITE_ARTICLES) %}
{{ wtf.quick_form(form) }}
{% endif %}
</div>
...
<!-- app/tempaltes/index.html -->
...
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="profile-thumbnail">
<a href="{{ url_for('.user_profile', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-content">
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author"><a href="{{ url_for('.user_profile', username=post.author.username) }}">{{ post.author.username }}</a></div>
<div class="post-body">{{ post.body }}</div>
</div>
</li>
{% endfor %}
</ul>
{% endblock %}
/* app/static/styles.css */
...
ul.posts {
list-style-type: none;
padding: 0px;
margin: 16px 0px 0px 0px;
border-top: 1px solid #e0e0e0;
}
ul.posts li.post {
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
ul.posts li.post:hover {
background-color: #f0f0f0;
}
...
...
div.post-date {
float: right;
}
div.post-author {
font-weight: bold;
}
div.post-thumbnail {
position: absolute;
}
div.post-content {
margin-left: 48px;
min-height: 48px;
}
$
git clone https://github.com/win911/flask_class.git$
git checkout 11a$
python manage.py db upgradeUser.posts
relationship.# app/main/views.py
#...
@main.route('/user/<username>')
def user_profile(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
posts = user.posts.order_by(Post.timestamp.desc()).all()
return render_template('user.html', user=user, posts=posts)
user.html
renders a list of blog posts like the one in index.html
._posts.html
and use Jinja2's include()
to includes the list from an external file._post.html
template name is not a requirment.<!-- app/tempaltes/_post.html -->
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="post-thumbnail">
<a href="{{ url_for('.user_profile', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-content">
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author"><a href="{{ url_for('.user_profile', username=post.author.username) }}">{{ post.author.username }}</a></div>
<div class="post-body">{{ post.body }}</div>
</div>
</li>
{% endfor %}
</ul>
index.html
¶_post.html
with include()
.<!-- app/tempaltes/index.html -->
...
{% include '_posts.html' %}
{% endblock %}
user.html
¶<!-- app/tempaltes/user.html -->
...
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
{% endblock %}
Paginate
the data and render it in chunks.ForgeryPy
package to generate fake data automatically.(venv) $ pip install forgerypy
requirements.txt
file can be replaced with a requirements
folder.dev.txt
file list the dependencies that are necessary for development.prod.txt
file list the dependencies that are needed in production.common.txt
file list the common dependencies in both stages.dev.txt
and prod.txt
use the -r
prefix to include the common.txt
.alembic==0.9.5
blinker==1.4
click==6.7
dominate==2.3.1
Flask==0.12.2
Flask-Bootstrap==3.3.7.1
Flask-JsonSchema==0.1.1
Flask-Login==0.4.0
Flask-Mail==0.9.1
Flask-Migrate==2.1.1
Flask-Moment==0.5.1
Flask-Script==2.0.5
Flask-SQLAlchemy==2.2
Flask-WTF==0.14.2
itsdangerous==0.24
Jinja2==2.9.6
jsonschema==2.6.0
Mako==1.0.7
MarkupSafe==1.0
python-dateutil==2.6.1
python-editor==1.0.3
six==1.11.0
SQLAlchemy==1.1.14
visitor==0.1.3
Werkzeug==0.12.2
WTForms==2.1
-r common.txt
-r common.txt
ForgeryPy==0.1
User
and Post
models that can generate fake data.IntegrityError
exception.# app/models.py
class User(UserMixin, db.Model):
#...
@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()
offset()
query filter.offset()
filter skips the number of results we generate from the randint()
function.first()
.# app/models.py
class Post(db.Model):
#...
@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, 3)),
timestamp=forgery_py.date.date(True),
author=u)
db.session.add(p)
db.session.commit()
Post
to the shell context¶# manage.py
#...
from app.models import User, Role, Post
#...
def make_shell_context():
return dict(app=app, db=db, User=User, Role=Role, Post=Post)
#...
(venv) $ python manage.py shell
>>> User.generate_fake(100)
>>> Post.generate_fake(100)
request.args
.type=int
ensures that if it is not an integer, the default is used.all()
with Flask-SQLAlchemy's paginate()
.paginate()
method takes the page
number as the first and only requried argument.per_page
argument can be given, the default is 20.error_out=True
will issue a 404 error code.# app/main/views.py
from flask import render_template, redirect, url_for, abort, flash, request, current_app
#...
@main.route('/', methods=['GET', 'POST'])
def index():
form = PostForm()
if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
post = Post(body=form.body.data, author=current_user._get_current_object())
db.session.add(post)
return redirect(url_for('.index'))
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)
# app/main/views.py
#...
@main.route('/user/<username>')
def user_profile(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
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)
FLASKY_POSTS_PER_PAGE
to config¶# config.py
#...
class Config(object):
#...
FLASKY_POSTS_PER_PAGE = 20
#...
paginate()
is an object of class Pagination
.Attribute | Description |
---|---|
items | The records in the current page |
query | The source query that was paginated |
page | The current page number |
prev_num | The previous page number |
next_num | The next page number |
has_next | True if there is a next page |
has_prev | True if there is a previous page |
pages | The total number of pages for the query |
per_page | The number of items per page |
total | The total number of items returned by the query |
iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2)
¶left_edge
pages on the left side, left_current
pages to the left of the current page, right_current
pages to the right of the current page, and right_edge
pages on the right side. None
, 48, 49, 50, 51, 52, 53, 54, 55, None
, 99, 100. None
value in the sequence indicates a gap in the sequence of pages.prev()
¶next()
¶iter_pages()
iterator.url_for()
....
<!-- app/tempaltes/_macros.html -->
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
<li{% if not pagination.has_prev %} class="disabled"{% endif %}>
<a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}">
«
</a>
</li>
{% for p in pagination.iter_pages() %}
{% if p %}
{% if p == pagination.page %}
<li class="active">
<a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
</li>
{% else %}
<li>
<a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="disabled"><a href="#">…</a></li>
{% endif %}
{% endfor %}
<li{% if not pagination.has_next %} class="disabled"{% endif %}>
<a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}">
»
</a>
</li>
</ul>
{% endmacro %}
index.html
and user.html
¶pagination_widget
macro below the _post.html
template.<!-- app/tempaltes/index.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}
...
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}
{% endblock %}
<!-- app/templates/user.html -->
{% extends "base.html" %}
{% import "_macros.html" as macros %}
...
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, '.user_profile', username=user.username) }}
</div>
{% endif %}
{% endblock %}
$
git clone https://github.com/win911/flask_class.git$
git checkout 11b(venv) $ pip install flask-pagedown markdown bleach
PageDownField
class that has the same interface as the TextAreaField
from WTForms.# app/__init__.py
#...
from flask_pagedown import PageDown
#...
pagedown = PageDown()
#...
def create_app(config_name):
#...
pagedown.init_app(app)
#...
body
field of the PostForm
to a PageDownField
.# app/main/forms.py
from flask_pagedown.fields import PageDownField
#...
class PostForm(FlaskForm):
body = PageDownField("What's on your mind?", validators=[Required()])
submit = SubmitField('Submit')
PageDown
libraries.<!-- app/tempaltes/index.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
{% block title %}Flasky{% endblock %}
...
POST
request.Post
model.Post
so the post can be edited later.Post
model¶# app/models.py
from markdown import markdown
import bleach
#...
class Post(db.Model):
#...
body_html = db.Column(db.Text)
#...
@staticmethod
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
'h1', 'h2', 'h3', 'p']
target.body_html = bleach.linkify(bleach.clean(
markdown(value, output_format='html'),
tags=allowed_tags, strip=True))
db.event.listen(Post.body, 'set', Post.on_changed_body)
post.body
with post.body_html
¶| safe
to tell Jinja 2 not to escapte the HTML elements.<!-- app/tempaltes/_posts.html -->
...
<div class="post-body">
{% if post.body_html %}
{{ post.body_html | safe }}
{% else %}
{{ post.body }}
{% endif %}
</div>
...
(venv) $ python manage.py db upgrade
$
git clone https://github.com/win911/flask_class.git$
git checkout 11c$
python manage.py db upgradeid
field assigned when the post is inserted in the database.post.html
template receives a list with just the post to render._posts.html
template here as well.# app/main/views.py
#...
@main.route('/post/<int:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', posts=[post])
<!-- app/tempaltes/post.html -->
{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Flasky - Post{% endblock %}
{% block page_content %}
{% include '_posts.html' %}
{% endblock %}
_post.html
¶<!-- app/tempaltes/_posts.html -->
<ul class="posts">
...
<div class="post-content">
...
<div class="post-footer">
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
$
git clone https://github.com/win911/flask_class.git$
git checkout 11d<!-- app/tempaltes/edit_post.html -->
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Post{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Post</h1>
</div>
<div>
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
# app/main/views.py
#...
@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
post = Post.query.get_or_404(id)
if current_user != post.author and \
not current_user.can(Permission.ADMINISTER):
abort(403)
form = PostForm()
if form.validate_on_submit():
post.body = form.body.data
db.session.add(post)
db.session.commit()
flash('The post has been updated.')
return redirect(url_for('.post', id=post.id))
form.body.data = post.body
return render_template('edit_post.html', form=form)
<!-- app/tempaltes/_posts.html -->
<ul class="posts">
...
<div class="post-content">
...
<div class="post-footer">
{% if current_user == post.author %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-primary">Edit</span>
</a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-danger">Edit [Admin]</span>
</a>
{% endif %}
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
$
git clone https://github.com/win911/flask_class.git$
git checkout 11e