原文:
zh.annas-archive.org/md5/A6963809F66F360038656FE5292ADA55译者:飞龙
第五章:用户身份验证
在本章中,我们将向我们的网站添加用户身份验证。能够区分一个用户和另一个用户使我们能够开发一整套新功能。例如,我们将看到如何限制对创建、编辑和删除视图的访问,防止匿名用户篡改网站内容。我们还可以向用户显示他们的草稿帖子,但对其他人隐藏。本章将涵盖向网站添加身份验证层的实际方面,并以讨论如何使用会话跟踪匿名用户结束。
在本章中,我们将:
-
创建一个数据库模型来表示用户
-
安装 Flask-Login 并将 LoginManager 助手添加到我们的站点
-
学习如何使用加密哈希函数安全存储和验证密码
-
构建用于登录和退出网站的表单和视图
-
查看如何在视图和模板中引用已登录用户
-
限制对已登录用户的视图访问
-
向 Entry 模型添加作者外键
-
使用 Flask 会话对象跟踪网站的任何访问者
创建用户模型
构建我们的身份验证系统的第一步将是创建一个表示单个用户帐户的数据库模型。我们将存储用户的登录凭据,以及一些额外的信息,如用户的显示名称和他们的帐户创建时间戳。我们的模型将具有以下字段:
-
email(唯一):存储用户的电子邮件地址,并将其用于身份验证 -
password_hash: 不是将每个用户的密码作为明文串联起来,而是使用单向加密哈希函数对密码进行哈希处理 -
name: 用户的名称,这样我们就可以在他们的博客条目旁边显示它 -
slug: 用户名称的 URL 友好表示,也是唯一的 -
active: 布尔标志,指示此帐户是否处于活动状态。只有活动用户才能登录网站 -
created_timestamp: 用户帐户创建的时间
提示
如果您认为还有其他字段可能有用,请随意向此列表添加自己的内容。
现在我们有了字段列表,让我们创建model类。打开models.py,在Tag模型下面,添加以下代码:
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(64), unique=True)
password_hash = db.Column(db.String(255))
name = db.Column(db.String(64))
slug = db.Column(db.String(64), unique=True)
active = db.Column(db.Boolean, default=True)
created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
def __init__(self, *args, **kwargs):
super(User, self).__init__(*args, **kwargs)
self.generate_slug()
def generate_slug(self):
if self.name:
self.slug = slugify(self.name)
正如您在第二章中所记得的,使用 SQLAlchemy 的关系数据库,我们需要创建一个迁移,以便将这个表添加到我们的数据库中。从命令行,我们将使用manage.py助手来审查我们的模型并生成迁移脚本:
(blog) $ python manage.py db migrate
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'user'
Generating /home/charles/projects/blog/app/migrations/versions/40ce2670e7e2_.py ... done
生成迁移后,我们现在可以运行db upgrade来进行模式更改:
(blog) $ python manage.py db upgrade
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.migration] Running upgrade 2ceb72931f66 -> 40ce2670e7e2, empty message
现在我们有了用户,下一步将允许他们登录网站。
安装 Flask-Login
Flask-Login 是一个轻量级的扩展,用于处理用户登录和退出网站。根据项目的文档,Flask-Login 将执行以下操作:
-
登录和退出网站的用户
-
将视图限制为已登录用户
-
管理 cookie 和“记住我”功能
-
帮助保护用户会话 cookie 免受盗窃
另一方面,Flask-Login 不会做以下事情:
-
对用户帐户的存储做出任何决定
-
管理用户名、密码、OpenID 或任何其他形式的凭据
-
处理分层权限或任何超出已登录或已注销的内容
-
帐户注册、激活或密码提醒
从这些列表中得出的结论是,Flask-Login 最好被认为是一个会话管理器。它只是管理用户会话,并让我们知道哪个用户正在发出请求,以及该用户是否已登录。
让我们开始吧。使用pip安装 Flask-Login:
(blog) $ pip install Flask-Login
Downloading/unpacking Flask-Login
...
Successfully installed Flask-Login
Cleaning up...
为了开始在我们的应用程序中使用这个扩展,我们将创建一个LoginManager类的实例,这是由 Flask-Login 提供的。除了创建LoginManager对象之外,我们还将添加一个信号处理程序,该处理程序将在每个请求之前运行。这个信号处理程序将检索当前登录的用户并将其存储在一个名为g的特殊对象上。在 Flask 中,g对象可以用来存储每个请求的任意值。
将以下代码添加到app.py。导入放在模块的顶部,其余部分放在末尾:
from flask import Flask, g
from flask.ext.login import LoginManager, current_user
# Add to the end of the module.
login_manager = LoginManager(app)
login_manager.login_view = "login"
@app.before_request
def _before_request():
g.user = current_user
现在我们已经创建了我们的login_manager并添加了一个信号处理程序来加载当前用户,我们需要告诉 Flask-Login 如何确定哪个用户已登录。Flask-Login 确定这一点的方式是将当前用户的 ID 存储在会话中。我们的用户加载器将接受存储在会话中的 ID 并从数据库返回一个User对象。
打开models.py并添加以下代码行:
from app import login_manager
@login_manager.user_loader
def _user_loader(user_id):
return User.query.get(int(user_id))
现在 Flask-Login 知道如何将用户 ID 转换为 User 对象,并且该用户将作为g.user对我们可用。
实现 Flask-Login 接口
为了让 Flask-Login 与我们的User模型一起工作,我们需要实现一些特殊方法,这些方法构成了 Flask-Login 接口。通过实现这些方法,Flask-Login 将能够接受一个User对象并确定他们是否可以登录网站。
打开models.py并向User类添加以下方法:
class User(db.Model):
# ... column definitions, etc ...
# Flask-Login interface..
def get_id(self):
return unicode(self.id)
def is_authenticated(self):
return True
def is_active(self):
return self.active
def is_anonymous(self):
return False
第一个方法get_id()指示 Flask-Login 如何确定用户的 ID,然后将其存储在会话中。它是我们用户加载器函数的反向,它给我们一个 ID 并要求我们返回一个User对象。其余的方法告诉 Flask-Login,数据库中的User对象不是匿名的,并且只有在active属性设置为True时才允许登录。请记住,Flask-Login 对我们的User模型或数据库一无所知,因此我们必须非常明确地告诉它。
现在我们已经配置了 Flask-Login,让我们添加一些代码,以便我们可以创建一些用户。
创建用户对象
创建新用户就像创建条目或标签一样,只有一个例外:我们需要安全地对用户的密码进行哈希处理。您永远不应该以明文形式存储密码,并且由于黑客的技术日益复杂,最好使用强大的加密哈希函数。我们将使用Flask-Bcrypt扩展来对我们的密码进行哈希处理和检查,因此让我们使用pip安装这个扩展:
(blog) $ pip install flask-bcrypt
...
Successfully installed Flask-Bcrypt
Cleaning up...
打开app.py并添加以下代码来注册扩展到我们的应用程序:
from flask.ext.bcrypt import Bcrypt
bcrypt = Bcrypt(app)
现在让我们为User对象添加一些方法,以便创建和检查密码变得简单:
from app import bcrypt
class User(db.Model):
# ... column definitions, other methods ...
@staticmethod
def make_password(plaintext):
return bcrypt.generate_password_hash(plaintext)
def check_password(self, raw_password):
return bcrypt.check_password_hash(self.password_hash, raw_password)
@classmethod
def create(cls, email, password, **kwargs):
return User(
email=email,
password_hash=User.make_password(password),
**kwargs)
@staticmethod
def authenticate(email, password):
user = User.query.filter(User.email == email).first()
if user and user.check_password(password):
return user
return False
make_password方法接受明文密码并返回哈希版本,而check_password方法接受明文密码并确定它是否与数据库中存储的哈希版本匹配。然而,我们不会直接使用这些方法。相反,我们将创建两个更高级的方法,create和authenticate。create方法将创建一个新用户,在保存之前自动对密码进行哈希处理,而authenticate方法将根据用户名和密码检索用户。
通过创建一个新用户来尝试这些方法。打开一个 shell,并使用以下代码作为示例,为自己创建一个用户:
In [1]: from models import User, db
In [2]: user = User.create("charlie@gmail.com", password="secret",
name="Charlie")
In [3]: print user.password
$2a$12$q.rRa.6Y2IEF1omVIzkPieWfsNJzpWN6nNofBxuMQDKn.As/8dzoG
In [4]: db.session.add(user)
In [5]: db.session.commit()
In [6]: User.authenticate("charlie@gmail.com", "secret")
Out[6]: <User u"Charlie">
In [7]: User.authenticate("charlie@gmail.com", "incorrect")
Out[7]: False
现在我们有了一种安全地存储和验证用户凭据的方法,我们可以开始构建登录和注销视图了。
登录和注销视图
用户将使用他们的电子邮件和密码登录我们的博客网站;因此,在我们开始构建实际的登录视图之前,让我们从LoginForm开始。这个表单将接受用户名、密码,并且还会呈现一个复选框来指示网站是否应该记住我。在app目录中创建一个forms.py模块,并添加以下代码:
import wtforms
from wtforms import validators
from models import User
class LoginForm(wtforms.Form):
email = wtforms.StringField("Email",
validators=[validators.DataRequired()])
password = wtforms.PasswordField("Password",
validators=[validators.DataRequired()])
remember_me = wtforms.BooleanField("Remember me?",
default=True)
提示
请注意,WTForms 还提供了一个电子邮件验证器。但是,正如该验证器的文档所告诉我们的那样,它非常原始,可能无法捕获所有边缘情况,因为完整的电子邮件验证实际上是非常困难的。
为了在正常的 WTForms 验证过程中验证用户的凭据,我们将重写表单的validate()方法。如果找不到电子邮件或密码不匹配,我们将在电子邮件字段下方显示错误。将以下方法添加到LoginForm类:
def validate(self):
if not super(LoginForm, self).validate():
return False
self.user = User.authenticate(self.email.data, self.password.data)
if not self.user:
self.email.errors.append("Invalid email or password.")
return False
return True
现在我们的表单已经准备好了,让我们创建登录视图。我们将实例化LoginForm并在POST时对其进行验证。此外,当用户成功验证时,我们将重定向他们到一个新页面。
当用户登录时,将其重定向回用户先前浏览的页面是一个很好的做法。为了实现这一点,我们将在查询字符串值next中存储用户先前所在页面的 URL。如果在该值中找到了 URL,我们可以将用户重定向到那里。如果未找到 URL,则用户将默认被重定向到主页。
在app目录中打开views.py并添加以下代码:
from flask import flash, redirect, render_template, request, url_for
from flask.ext.login import login_user
from app import app
from app import login_manager
from forms import LoginForm
@app.route("/")
def homepage():
return render_template("homepage.html")
@app.route("/login/", methods=["GET", "POST"])
def login():
if request.method == "POST":
form = LoginForm(request.form)
if form.validate():
login_user(form.user, remember=form.remember_me.data)
flash("Successfully logged in as %s." % form.user.email, "success")
return redirect(request.args.get("next") or url_for("homepage"))
else:
form = LoginForm()
return render_template("login.html", form=form)
魔法发生在我们成功验证表单(因此验证了用户身份)后的POST上。我们调用login_user,这是 Flask-Login 提供的一个辅助函数,用于设置正确的会话值。然后我们设置一个闪存消息并将用户送上路。
登录模板
login.html模板很简单,除了一个技巧,一个例外。在表单的 action 属性中,我们指定了url_for('login'),但我们还传递了一个额外的值next。这允许我们在用户登录时保留所需的下一个 URL。将以下代码添加到templates/login.html:
{% extends "base.html" %}
{% from "macros/form_field.html" import form_field %}
{% block title %}Log in{% endblock %}
{% block content_title %}Log in{% endblock %}
{% block content %}
<form action="{{ url_for('login', next=request.args.get('next','')) }}" class="form form-horizontal" method="post">
{{ form_field(form.email) }}
{{ form_field(form.password) }}
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<div class="checkbox">
<label>{{ form.remember_me() }} Remember me</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Log in</button>
<a class="btn" href="{{ url_for('homepage') }}">Cancel</a>
</div>
</div>
</form>
{% endblock %}
当您访问登录页面时,您的表单将如下截图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_05_01.jpg
注销
最后让我们添加一个视图,用于将用户从网站中注销。有趣的是,此视图不需要模板,因为用户将简单地通过视图,在其会话注销后被重定向。将以下import语句和注销视图代码添加到views.py:
# Modify the import at the top of the module.
from flask.ext.login import login_user, logout_user # Add logout_user
@app.route("/logout/")
def logout():
logout_user()
flash('You have been logged out.', 'success')
return redirect(request.args.get('next') or url_for('homepage'))
再次说明,我们接受next URL 作为查询字符串的一部分,默认为主页,如果未指定 URL。
访问当前用户
让我们在导航栏中创建登录和注销视图的链接。为此,我们需要检查当前用户是否已经通过身份验证。如果是,我们将显示一个指向注销视图的链接;否则,我们将显示一个登录链接。
正如您可能还记得本章早些时候所说的,我们添加了一个信号处理程序,将当前用户存储为 Flask g对象的属性。我们可以在模板中访问这个对象,所以我们只需要在模板中检查g.user是否已经通过身份验证。
打开base.html并对导航栏进行以下添加:
<ul class="nav navbar-nav">
<li><a href="{{ url_for('homepage') }}">Home</a></li>
<li><a href="{{ url_for('entries.index') }}">Blog</a></li>
{% if g.user.is_authenticated %}
<li><a href="{{ url_for('logout', next=request.path) }}">Log
out</a></li>
{% else %}
<li><a href="{{ url_for('login', next=request.path) }}">Log
in</a></li>
{% endif %}
{% block extra_nav %}{% endblock %}
</ul>
注意我们如何调用is_authenticated()方法,这是我们在User模型上实现的。Flask-Login 为我们提供了一个特殊的AnonymousUserMixin,如果当前没有用户登录,将使用它。
还要注意的是,除了视图名称,我们还指定了next=request.path。这与我们的登录和注销视图配合使用,以便在单击登录或注销后将用户重定向到其当前页面。
限制对视图的访问
目前,我们所有的博客视图都是不受保护的,任何人都可以访问它们。为了防止恶意用户破坏我们的条目,让我们为实际修改数据的视图添加一些保护。Flask-Login 提供了一个特殊的装饰器login_required,我们将使用它来保护应该需要经过身份验证的视图。
让我们浏览条目蓝图并保护所有修改数据的视图。首先在blueprint.py模块的顶部添加以下导入:
from flask.ext.login import login_required
login_required是一个装饰器,就像app.route一样,所以我们只需包装我们希望保护的视图。例如,这是如何保护image_upload视图的方法:
@entries.route('/image-upload/', methods=['GET', 'POST'])
@login_required
def image_upload():
...
浏览模块,并在以下视图中添加login_required装饰器,注意要在路由装饰器下面添加:
-
image_upload -
create -
edit -
删除
当匿名用户尝试访问这些视图时,他们将被重定向到login视图。作为额外的奖励,Flask-Login 将在重定向到login视图时自动处理指定下一个参数,因此用户将返回到他们试图访问的页面。
存储条目的作者
正如您可能还记得我们在第一章中创建的规范,创建您的第一个 Flask 应用程序,我们的博客网站将支持多个作者。当创建条目时,我们将把当前用户存储在条目的作者列中。为了存储编写给定Entry的User,我们将在用户和条目之间创建一个一对多的关系,以便一个用户可以有多个条目:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_05_02.jpg
为了创建一对多的关系,我们将在Entry模型中添加一个指向User表中用户的列。这个列将被命名为author_id,因为它引用了一个User,我们将把它设为外键。打开models.py并对Entry模型进行以下修改:
class Entry(db.Model):
modified_timestamp = ...
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
tags = ...
由于我们添加了一个新的列,我们需要再次创建一个迁移。从命令行运行db migrate和db upgrade:
(blog) $ python manage.py db migrate
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'entry.author_id'
Generating /home/charles/projects/blog/app/migrations/versions/33011181124e_.py ... done
(blog) $ python manage.py db upgrade
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.migration] Running upgrade 40ce2670e7e2 -> 33011181124e, empty message
就像我们对标签所做的那样,最后一步将是在用户模型上创建一个反向引用,这将允许我们访问特定用户关联的Entry行。因为用户可能有很多条目,我们希望对其执行额外的过滤操作,我们将把反向引用暴露为一个查询,就像我们为标签条目所做的那样。
在User类中,在created_timestamp列下面添加以下代码行:
entries = db.relationship('Entry', backref='author', lazy='dynamic')
现在我们有能力将User作为博客条目的作者存储起来,下一步将是在创建条目时填充这个列。
注意
如果数据库中有任何博客条目,我们还需要确保它们被分配给一个作者。从交互式 shell 中,让我们手动更新所有现有条目上的作者字段:
In [8]: Entry.query.update({"author_id": user.id})
Out[8]: 6
这个查询将返回更新的行数,在这种情况下是数据库中的条目数。要保存这些更改,再次调用commit():
In [9]: db.session.commit()
设置博客条目的作者
现在我们有一个适合存储Entry作者的列,并且能够访问当前登录的用户,我们可以通过在创建条目时设置条目的作者来利用这些信息。在每个请求之前,我们的信号处理程序将把当前用户添加到 Flask g对象上,由于create视图受login_required装饰器保护,我们知道g.user将是来自数据库的User。
因为我们正在使用g 对象来访问用户,所以我们需要导入它,所以在条目蓝图的顶部添加以下导入语句:
from flask import g
在条目蓝图中,我们现在需要修改Entry对象的实例化,手动设置作者属性。对create视图进行以下更改:
if form.validate():
entry = form.save_entry(Entry(author=g.user))
db.session.add(entry)
当您要创建一个条目时,您现在将被保存在数据库中作为该条目的作者。试一试吧。
保护编辑和删除视图
如果多个用户能够登录到我们的网站,没有什么可以阻止恶意用户编辑甚至删除另一个用户的条目。这些视图受login_required装饰器保护,但我们需要添加一些额外的代码来确保只有作者可以编辑或删除他们自己的条目。
为了清晰地实现此保护,我们将再次重构条目蓝图中的辅助函数。对条目蓝图进行以下修改:
def get_entry_or_404(slug, author=None):
query = Entry.query.filter(Entry.slug == slug)
if author:
query = query.filter(Entry.author == author)
else:
query = filter_status_by_user(query)
return query.first_or_404()
我们引入了一个新的辅助函数filter_status_by_user。此函数将确保匿名用户无法看到草稿条目。在get_entry_or_404下方的条目蓝图中添加以下函数:
def filter_status_by_user(query):
if not g.user.is_authenticated:
return query.filter(Entry.status == Entry.STATUS_PUBLIC)
else:
return query.filter(
Entry.status.in_((Entry.STATUS_PUBLIC,
Entry.STATUS_DRAFT)))
为了限制对edit和delete视图的访问,我们现在只需要将当前用户作为作者参数传递。对编辑和删除视图进行以下修改:
entry = get_entry_or_404(slug, author=None)
如果您尝试访问您未创建的条目的edit或delete视图,您将收到404响应。
最后,让我们修改条目详细模板,以便除了条目的作者之外,所有用户都无法看到编辑和删除链接。在您的entries应用程序中编辑模板entries/detail.html,您的代码可能如下所示:
{% if g.user == entry.author %}
<li><h4>Actions</h4></li>
<li><a href="{{ url_for('entries.edit', slug=entry.slug)
}}">Edit</a></li>
<li><a href="{{ url_for('entries.delete', slug=entry.slug)
}}">Delete</a></li>
{% endif %}
显示用户的草稿
我们的条目列表仍然存在一个小问题:草稿条目显示在普通条目旁边。我们不希望向任何人显示未完成的条目,但同时对于用户来说,看到自己的草稿将是有帮助的。因此,我们将修改条目列表和详细信息,只向条目的作者显示公共条目。
我们将再次修改条目蓝图中的辅助函数。我们将首先修改filter_status_by_user函数,以允许已登录用户查看自己的草稿(但不是其他人的):
def filter_status_by_user(query):
if not g.user.is_authenticated:
query = query.filter(Entry.status == Entry.STATUS_PUBLIC)
else:
# Allow user to view their own drafts.
query = query.filter(
(Entry.status == Entry.STATUS_PUBLIC) |
((Entry.author == g.user) &
(Entry.status != Entry.STATUS_DELETED)))
return query
新的查询可以解析为:“给我所有公共条目,或者我是作者的未删除条目。”
由于get_entry_or_404已经使用了filter_status_by_user辅助函数,因此detail、edit和delete视图已经准备就绪。我们只需要处理使用entry_list辅助函数的各种列表视图。让我们更新entry_list辅助函数以使用新的filter_status_by_user辅助函数:
query = filter_status_by_user(query)
valid_statuses = (Entry.STATUS_PUBLIC, Entry.STATUS_DRAFT)
query = query.filter(Entry.status.in_(valid_statuses))
if request.args.get("q"):
search = request.args["q"]
query = query.filter(
(Entry.body.contains(search)) |
(Entry.title.contains(search)))
return object_list(template, query, **context)
就是这样!我希望这展示了一些辅助函数在正确的位置上是如何真正简化开发者生活的。在继续进行最后一节之前,我建议创建一个或两个用户,并尝试新功能。
如果您计划在您的博客上支持多个作者,您还可以添加一个作者索引页面(类似于标签索引),以及列出与特定作者相关联的条目的作者详细页面(user.entries)。
会话
当您通过本章工作时,您可能会想知道 Flask-Login(以及 Flask)是如何能够在请求之间确定哪个用户已登录的。Flask-Login 通过将用户的 ID 存储在称为会话的特殊对象中来实现这一点。会话利用 cookie 来安全地存储信息。当用户向您的 Flask 应用程序发出请求时,他们的 cookie 将随请求一起发送,Flask 能够检查 cookie 数据并将其加载到会话对象中。同样,您的视图可以添加或修改存储在会话中的信息,从而在此过程中更新用户的 cookie。
Flask 会话对象的美妙之处在于它可以用于站点的任何访问者,无论他们是否已登录。会话可以像普通的 Python 字典一样处理。以下代码显示了您如何使用会话跟踪用户访问的最后一个页面:
from flask import request, session
@app.before_request
def _last_page_visited():
if "current_page" in session:
session["last_page"] = session["current_page"]
session["current_page"] = request.path
默认情况下,Flask 会话只持续到浏览器关闭。如果您希望会话持久存在,即使在重新启动之间也是如此,只需设置session.permanent = True。
提示
与g对象一样,session对象可以直接从模板中访问。
作为练习,尝试为您的网站实现一个简单的主题选择器。创建一个视图,允许用户选择颜色主题,并将其存储在会话中。然后,在模板中,根据用户选择的主题应用额外的 CSS 规则。
总结
在本章中,我们为博客应用程序添加了用户身份验证。我们创建了一个User模型,安全地将用户的登录凭据存储在数据库中,然后构建了用于登录和退出站点的视图。我们添加了一个信号处理程序,在每个请求之前运行并检索当前用户,然后学习如何在视图和模板中使用这些信息。在本章的后半部分,我们将User模型与 Entry 模型集成,从而在过程中使我们的博客更加安全。本章以对 Flask 会话的简要讨论结束。
在下一章中,我们将构建一个管理仪表板,允许超级用户执行诸如创建新用户和修改站点内容等操作。我们还将收集和显示各种站点指标,如页面浏览量,以帮助可视化哪些内容驱动了最多的流量。
第六章:构建管理仪表板
在本章中,我们将为我们的网站构建一个管理仪表板。我们的管理仪表板将使特定的、选择的用户能够管理整个网站上的所有内容。实质上,管理站点将是数据库的图形前端,支持在应用程序表中创建、编辑和删除行的操作。优秀的 Flask-Admin 扩展几乎提供了所有这些功能,但我们将超越默认值,扩展和定制管理页面。
在本章中,我们将:
-
安装 Flask-Admin 并将其添加到我们的网站
-
添加用于处理
Entry、Tag和User模型的视图 -
添加管理网站静态资产的视图
-
将管理与 Flask-Login 框架集成
-
创建一个列来标识用户是否为管理员
-
为管理仪表板创建一个自定义索引页面
安装 Flask-Admin
Flask-Admin 为 Flask 应用程序提供了一个现成的管理界面。Flask-Admin 还与 SQLAlchemy 很好地集成,以提供用于管理应用程序模型的视图。
下面的图像是对本章结束时Entry管理员将会是什么样子的一个 sneak preview:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_01.jpg
虽然这种功能需要相对较少的代码,但我们仍然有很多内容要涵盖,所以让我们开始吧。首先使用pip将Flask-Admin安装到virtualenv中。在撰写本文时,Flask-Admin 的当前版本是 1.0.7。
(blog) $ pip install Flask-Admin
Downloading/unpacking Flask-Admin
...
Successfully installed Flask-Admin
Cleaning up...
如果您希望测试它是否安装正确,可以输入以下代码:
(blog) $ python manage.py shell
In [1]: from flask.ext import admin
In [2]: print admin.__version__
1.0.7
将 Flask-Admin 添加到我们的应用程序
与我们应用程序中的其他扩展不同,我们将在其自己的模块中设置管理扩展。我们将编写几个特定于管理的类,因此将它们放在自己的模块中是有意义的。在app目录中创建一个名为admin.py的新模块,并添加以下代码:
from flask.ext.admin import Admin
from app import app
admin = Admin(app, 'Blog Admin')
因为我们的admin模块依赖于app模块,为了避免循环导入,我们需要确保在app之后加载admin。打开main.py模块并添加以下内容:
from flask import request, session
from app import app, db
import admin # This line is new, placed after the app import.
import models
import views
现在,您应该能够启动开发服务器并导航到/admin/以查看一个简单的管理员仪表板-默认的仪表板,如下图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_02.jpg
随着您在本章中的进展,我们将把这个无聊和普通的管理界面变成一个丰富而强大的仪表板,用于管理您的博客。
通过管理公开模型
Flask-Admin 带有一个contrib包,其中包含专门设计用于与 SQLAlchemy 模型一起工作的特殊视图类。这些类提供开箱即用的创建、读取、更新和删除功能。
打开admin.py并更新以下代码:
from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView
from app import app, db
from models import Entry, Tag, User
admin = Admin(app, 'Blog Admin')
admin.add_view(ModelView(Entry, db.session))
admin.add_view(ModelView(Tag, db.session))
admin.add_view(ModelView(User, db.session))
请注意我们如何调用admin.add_view()并传递ModelView类的实例,以及db会话,以便它可以访问数据库。Flask-Admin 通过提供一个中央端点来工作,我们开发人员可以向其中添加我们自己的视图。
启动开发服务器并尝试再次打开您的管理站点。它应该看起来像下面的截图:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_03.jpg
尝试通过在导航栏中选择其链接来点击我们模型的视图之一。点击Entry链接以干净的表格格式显示数据库中的所有条目。甚至有链接可以创建、编辑或删除条目,如下一个截图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_04.jpg
Flask-Admin 提供的默认值很好,但是如果您开始探索界面,您会开始注意到一些微妙的东西可以改进或清理。例如,可能不需要将 Entry 的正文文本包括在列中。同样,状态列显示状态为整数,但我们更希望看到与该整数相关联的名称。我们还可以单击每个Entry行中的铅笔图标。这将带您到默认的编辑表单视图,您可以使用它来修改该条目。
所有看起来都像下面的截图:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_05.jpg
如前面的截图所示,Flask-Admin 在处理我们的外键到键和多对多字段(作者和标签)方面做得非常出色。它还相当不错地选择了要为给定字段使用哪个 HTML 小部件,如下所示:
-
标签可以使用漂亮的多选小部件添加和删除
-
作者可以使用下拉菜单选择
-
条目正文方便地显示为文本区域
不幸的是,这个表单存在一些明显的问题,如下所示:
-
字段的排序似乎是任意的。
-
Slug字段显示为可编辑文本输入,因为这是由数据库模型管理的。相反,此字段应该从 Entry 的标题自动生成。
-
状态字段是一个自由格式的文本输入字段,但应该是一个下拉菜单,其中包含人类可读的状态标签,而不是数字。
-
创建时间戳和修改时间戳字段看起来是可编辑的,但应该自动生成。
在接下来的部分中,我们将看到如何自定义Admin类和ModelView类,以便管理员真正为我们的应用程序工作。
自定义列表视图
让我们暂时把表单放在一边,专注于清理列表。为此,我们将创建一个 Flask-Admin 的子类ModelView。ModelView类提供了许多扩展点和属性,用于控制列表显示的外观和感觉。
我们将首先通过手动指定我们希望显示的属性来清理列表列。此外,由于我们将在单独的列中显示作者,我们将要求 Flask-Admin 从数据库中高效地获取它。打开admin.py并更新以下代码:
from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView
from app import app, db
from models import Entry, Tag, User
class EntryModelView(ModelView):
column_list = [
'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
]
column_select_related_list = ['author'] # Efficiently SELECT the author.
admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(ModelView(Tag, db.session))
admin.add_view(ModelView(User, db.session))
您可能会注意到tease和tag_list实际上不是我们Entry模型中的列名。Flask-Admin 允许您使用任何属性作为列值。我们还指定要用于创建对其他模型的引用的列。打开models.py模块,并向Entry模型添加以下属性:
@property
def tag_list(self):
return ', '.join(tag.name for tag in self.tags)
@property
def tease(self):
return self.body[:100]
现在,当您访问Entry管理员时,您应该看到一个干净、可读的表格,如下图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_06.jpg
让我们也修复状态列的显示。这些数字很难记住 - 最好显示人类可读的值。Flask-Admin 带有枚举字段(如状态)的辅助程序。我们只需要提供要显示值的状态值的映射,Flask-Admin 就会完成剩下的工作。在EntryModelView中进行以下添加:
class EntryModelView(ModelView):
_status_choices = [(choice, label) for choice, label in [
(Entry.STATUS_PUBLIC, 'Public'),
(Entry.STATUS_DRAFT, 'Draft'),
(Entry.STATUS_DELETED, 'Deleted'),
]]
column_choices = {
'status': _status_choices,
}
column_list = [
'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
]
column_select_related_list = ['author']
我们的Entry列表视图看起来好多了。现在让我们对User列表视图进行一些改进。同样,我们将对ModelView进行子类化,并指定要覆盖的属性。在admin.py中在EntryModelView下面添加以下类:
class UserModelView(ModelView):
column_list = ['email', 'name', 'active', 'created_timestamp']
# Be sure to use the UserModelView class when registering the User:
admin.add_view(UserModelView(User, db.session))
以下截图显示了我们对User列表视图的更改:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_07.jpg
向列表视图添加搜索和过滤
除了显示我们的模型实例列表外,Flask-Admin 还具有强大的搜索和过滤功能。假设我们有大量条目,并且想要找到包含特定关键字(如 Python)的条目。如果我们能够在列表视图中输入我们的搜索,并且 Flask-Admin 只列出标题或正文中包含单词’Python’的条目,那将是有益的。
正如您所期望的那样,这是非常容易实现的。打开admin.py并添加以下行:
class EntryModelView(ModelView):
_status_choices = [(choice, label) for choice, label in [
(Entry.STATUS_PUBLIC, 'Public'),
(Entry.STATUS_DRAFT, 'Draft'),
(Entry.STATUS_DELETED, 'Deleted'),
]]
column_choices = {
'status': _status_choices,
}
column_list = [
'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
]
column_searchable_list = ['title', 'body']
column_select_related_list = ['author']
当您重新加载Entry列表视图时,您将看到一个新的文本框,允许您搜索title和body字段,如下面的屏幕截图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_08.jpg
尽管全文搜索可能非常有用,但对于状态或创建时间戳等非文本字段,拥有更强大的过滤能力会更好。再次,Flask-Admin 提供了易于使用、易于配置的过滤选项,来拯救我们。
让我们通过向Entry列表添加几个过滤器来看看过滤器是如何工作的。我们将再次修改EntryModelView如下:
class EntryModelView(ModelView):
_status_choices = [(choice, label) for choice, label in [
(Entry.STATUS_PUBLIC, 'Public'),
(Entry.STATUS_DRAFT, 'Draft'),
(Entry.STATUS_DELETED, 'Deleted'),
]]
column_choices = {
'status': _status_choices,
}
column_filters = [
'status', User.name, User.email, 'created_timestamp'
]
column_list = [
'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
]
column_searchable_list = ['title', 'body']
column_select_related_list = ['author']
column_filters属性包含Entry模型上的列名称,以及来自User的相关模型的字段:
column_filters = [
'status', User.name, User.email, 'created_timestamp'
]
当您访问Entry列表视图时,您现在将看到一个名为添加过滤器的新下拉菜单。尝试各种数据类型。请注意,当您尝试在状态列上进行过滤时,Flask-Admin 会自动使用Public、Draft和Deleted标签。还要注意,当您在创建时间戳上进行过滤时,Flask-Admin 会呈现一个漂亮的日期/时间选择器小部件。在下面的屏幕截图中,我设置了各种过滤器:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_09.jpg
此时,Entry列表视图非常实用。作为练习,为User ModelView设置column_filters和column_searchable_list属性。
自定义管理模型表单
我们将通过展示如何自定义表单类来结束模型视图的讨论。您会记得,默认表单由 Flask-Admin 提供的有一些限制。在本节中,我们将展示如何自定义用于创建和编辑模型实例的表单字段的显示。
我们的目标是删除多余的字段,并为状态字段使用更合适的小部件,实现以下屏幕截图中所示的效果:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_10.jpg
为了实现这一点,我们首先手动指定我们希望在表单上显示的字段列表。这是通过在EntryModelView 类上指定form_columns属性来完成的:
class EntryModelView(ModelView):
...
form_columns = ['title', 'body', 'status', 'author', 'tags']
此外,我们希望status字段成为一个下拉小部件,使用各种状态的可读标签。由于我们已经定义了状态选择,我们将指示 Flask-Admin 使用 WTForms SelectField覆盖status字段,并传入有效选择的列表:
from wtforms.fields import SelectField # At top of module.
class EntryModelView(ModelView):
...
form_args = {
'status': {'choices': _status_choices, 'coerce': int},
}
form_columns = ['title', 'body', 'status', 'author', 'tags']
form_overrides = {'status': SelectField}
默认情况下,用户字段将显示为一个带有简单类型的下拉菜单。不过,想象一下,如果此列表包含数千个用户!这将导致一个非常大的查询和一个慢的渲染时间,因为需要创建所有的<option>元素。
当包含外键的表单呈现到非常大的表时,Flask-Admin 允许我们使用 Ajax 来获取所需的行。将以下属性添加到EntryModelView,现在您的用户将通过 Ajax 高效加载:
form_ajax_refs = {
'author': {
'fields': (User.name, User.email),
},
}
这个指令告诉 Flask-Admin,当我们查找作者时,它应该允许我们在作者的姓名或电子邮件上进行搜索。以下屏幕截图显示了它的外观:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_11.jpg
我们现在有一个非常漂亮的Entry表单。
增强用户表单
因为密码在数据库中以哈希形式存储,直接显示或编辑它们的价值很小。然而,在User表单上,我们将使输入新密码来替换旧密码成为可能。就像我们在Entry表单上对status字段所做的那样,我们将指定一个表单字段覆盖。然后,在模型更改处理程序中,我们将在保存时更新用户的密码。
对UserModelView模块进行以下添加:
from wtforms.fields import PasswordField # At top of module.
class UserModelView(ModelView):
column_filters = ('email', 'name', 'active')
column_list = ['email', 'name', 'active', 'created_timestamp']
column_searchable_list = ['email', 'name']
form_columns = ['email', 'password', 'name', 'active']
form_extra_fields = {
'password': PasswordField('New password'),
}
def on_model_change(self, form, model, is_created):
if form.password.data:
model.password_hash = User.make_password(form.password.data)
return super(UserModelView, self).on_model_change(
form, model, is_created)
以下截图显示了新的User表单的样子。如果您希望更改用户的密码,只需在新密码字段中输入新密码即可。
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_12.jpg
生成 slug
仍然有一个方面需要解决。当创建新的Entry、User或Tag对象时,Flask-Admin 将无法正确生成它们的slug。这是由于 Flask-Admin 在保存时实例化新模型实例的方式。为了解决这个问题,我们将创建一些ModelView的子类,以确保为Entry、User和Tag对象正确生成slug。
打开admin.py文件,并在模块顶部添加以下类:
class BaseModelView(ModelView):
pass
class SlugModelView(BaseModelView):
def on_model_change(self, form, model, is_created):
model.generate_slug()
return super(SlugModelView, self).on_model_change(
form, model, is_created)
这些更改指示 Flask-Admin,每当模型更改时,应重新生成 slug。
为了开始使用这个功能,更新EntryModelView和UserModelView模块以扩展SlugModelView类。对于Tag模型,直接使用SlugModelView类进行注册即可。
总结一下,您的代码应该如下所示:
from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView
from wtforms.fields import SelectField
from app import app, db
from models import Entry, Tag, User, entry_tags
class BaseModelView(ModelView):
pass
class SlugModelView(BaseModelView):
def on_model_change(self, form, model, is_created):
model.generate_slug()
return super(SlugModelView, self).on_model_change(
form, model, is_created)
class EntryModelView(SlugModelView):
_status_choices = [(choice, label) for choice, label in [
(Entry.STATUS_PUBLIC, 'Public'),
(Entry.STATUS_DRAFT, 'Draft'),
(Entry.STATUS_DELETED, 'Deleted'),
]]
column_choices = {
'status': _status_choices,
}
column_filters = ['status', User.name, User.email, 'created_timestamp']
column_list = [
'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
]
column_searchable_list = ['title', 'body']
column_select_related_list = ['author']
form_ajax_refs = {
'author': {
'fields': (User.name, User.email),
},
}
form_args = {
'status': {'choices': _status_choices, 'coerce': int},
}
form_columns = ['title', 'body', 'status', 'author', 'tags']
form_overrides = {'status': SelectField}
class UserModelView(SlugModelView):
column_filters = ('email', 'name', 'active')
column_list = ['email', 'name', 'active', 'created_timestamp']
column_searchable_list = ['email', 'name']
form_columns = ['email', 'password', 'name', 'active']
form_extra_fields = {
'password': PasswordField('New password'),
}
def on_model_change(self, form, model, is_created):
if form.password.data:
model.password_hash = User.make_password(form.password.data)
return super(UserModelView, self).on_model_change(
form, model, is_created)
admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(SlugModelView(Tag, db.session))
admin.add_view(UserModelView(User, db.session))
这些更改确保正确生成 slug,无论是保存现有对象还是创建新对象。
通过管理员管理静态资产
Flask-Admin 提供了一个方便的界面,用于管理静态资产(或磁盘上的其他文件),作为管理员仪表板的扩展。让我们向我们的网站添加一个FileAdmin,它将允许我们上传或修改应用程序的static目录中的文件。
打开admin.py文件,并在文件顶部导入以下模块:
from flask.ext.admin.contrib.fileadmin import FileAdmin
然后,在各种ModelView实现下,添加以下突出显示的代码行:
class BlogFileAdmin(FileAdmin):
pass
admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(SlugModelView(Tag, db.session))
admin.add_view(UserModelView(User, db.session))
admin.add_view(
BlogFileAdmin(app.config['STATIC_DIR'], '/static/', name='Static Files'))
在浏览器中打开管理员,您应该会看到一个名为静态文件的新选项卡。单击此链接将带您进入一个熟悉的文件浏览器,如下截图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_13.jpg
提示
如果您在管理文件时遇到问题,请确保为static目录及其子目录设置了正确的权限。
保护管理员网站
当您测试新的管理员网站时,您可能已经注意到它没有进行任何身份验证。为了保护我们的管理员网站免受匿名用户(甚至某些已登录用户)的侵害,我们将向User模型添加一个新列,以指示用户可以访问管理员网站。然后,我们将使用 Flask-Admin 提供的钩子来确保请求用户具有权限。
第一步是向我们的User模型添加一个新列。将admin列添加到User模型中,如下所示:
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(64), unique=True)
password_hash = db.Column(db.String(255))
name = db.Column(db.String(64))
slug = db.Column(db.String(64), unique=True)
active = db.Column(db.Boolean, default=True)
admin = db.Column(db.Boolean, default=False)
created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
现在我们将使用 Flask-Migrate 扩展生成模式迁移:
(blog) $ python manage.py db migrate
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'user.admin'
Generating /home/charles/projects/blog/app/migrations/versions/33011181124e_.py ... done
(blog) $ python manage.py db upgrade
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.migration] Running upgrade 40ce2670e7e2 -> 33011181124e, empty message
让我们还向User模型添加一个方法,用于告诉我们给定的用户是否是管理员。将以下方法添加到User模型中:
class User(db.Model):
# ...
def is_admin(self):
return self.admin
这可能看起来很傻,但如果您希望更改应用程序确定用户是否为管理员的语义,这是很好的代码规范。
在继续下一节之前,您可能希望修改UserModelView类,将admin列包括在column_list、column_filters和form_columns中。
创建身份验证和授权混合
由于我们在管理员视图中创建了几个视图,我们需要一种可重复使用的表达我们身份验证逻辑的方法。我们将通过组合实现此重用。您已经在视图装饰器(@login_required)的形式中看到了组合-装饰器只是组合多个函数的一种方式。Flask-Admin 有点不同,它使用 Python 类来表示单个视图。我们将使用一种友好于类的组合方法,称为mixins,而不是函数装饰器。
mixin 是提供方法覆盖的类。在 Flask-Admin 的情况下,我们希望覆盖的方法是is_accessible方法。在这个方法内部,我们将检查当前用户是否已经验证。
为了访问当前用户,我们必须在admin模块的顶部导入特殊的g对象:
from flask import g, url_for
在导入语句下面,添加以下类:
class AdminAuthentication(object):
def is_accessible(self):
return g.user.is_authenticated and g.user.is_admin()
最后,我们将通过 Python 的多重继承将其与其他几个类混合在一起。对BaseModelView 类进行以下更改:
class BaseModelView(AdminAuthentication, ModelView):
pass
还有BlogFileAdmin 类:
class BlogFileAdmin(AdminAuthentication, FileAdmin):
pass
如果尝试访问/admin/entry/等管理员视图 URL 而不符合is_accessible条件,Flask-Admin 将返回 HTTP 403 Forbidden 响应,如下截图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_14.jpg
注意
由于我们没有对Tag管理员模型进行更改,因此仍然可以访问。我们将由您来解决如何保护它。
设置自定义首页
我们的管理员着陆页(/admin/)非常无聊。实际上,除了导航栏之外,它根本没有任何内容。Flask-Admin 允许我们指定自定义索引视图,我们将使用它来显示一个简单的问候语。
为了添加自定义索引视图,我们需要导入几个新的帮助程序。将以下突出显示的导入添加到admin模块的顶部:
from flask.ext.admin import Admin, AdminIndexView, expose
from flask import redirect请求提供@expose装饰器,就像 Flask 本身使用@route一样。由于这个视图是索引,我们将要暴露的 URL 是/。以下代码将创建一个简单的索引视图,用于呈现模板。请注意,在初始化Admin对象时,我们将索引视图指定为参数:
class IndexView(AdminIndexView):
@expose('/')
def index(self):
return self.render('admin/index.html')
admin = Admin(app, 'Blog Admin', index_view=IndexView())
最后还缺少一件事:身份验证。由于用户通常会直接访问/admin/来访问管理员,因此检查索引视图中当前用户是否经过身份验证将非常方便。我们可以通过以下方式来检查:当前用户是否经过身份验证。
class IndexView(AdminIndexView):
@expose('/')
def index(self):
if not (g.user.is_authenticated and g.user.is_admin()):
return redirect(url_for('login', next=request.path))
return self.render('admin/index.html')
Flask-Admin 模板
Flask-Admin 提供了一个简单的主模板,您可以扩展它以创建统一的管理员站点外观。Flask-Admin 主模板包括以下区块:
| 区块名称 | 描述 |
|---|---|
head_meta | 头部页面元数据 |
title | 页面标题 |
head_css | 头部的 CSS 链接 |
head | 文档头部的任意内容 |
page_body | 页面布局 |
brand | 菜单栏中的标志 |
main_menu | 主菜单 |
menu_links | 导航栏 |
access_control | 菜单栏右侧的区域,可用于添加登录/注销按钮 |
messages | 警报和各种消息 |
body | 主内容区域 |
tail | 内容下方的空白区域 |
对于这个示例,body块对我们来说最有趣。在应用程序的templates目录中,创建一个名为admin的新子目录,其中包含一个名为index.html的空文件。
让我们自定义管理员着陆页,以在服务器上显示当前日期和时间。我们将扩展 Flask-Admin 提供的master模板,仅覆盖body块。在模板中创建admin目录,并将以下代码添加到templates/admin/index.html:
{% extends "admin/master.html" %}
{% block body %}
<h3>Hello, {{ g.user.name }}</h3>
{% endblock %}
以下是我们新着陆页的截图:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_06_15.jpg
这只是一个例子,用来说明扩展和定制管理面板是多么简单。尝试使用各种模板块,看看是否可以在导航栏中添加一个注销按钮。
阅读更多
Flask-Admin 是一个多才多艺、高度可配置的 Flask 扩展。虽然我们介绍了 Flask-Admin 的一些常用功能,但是要讨论的功能实在太多,无法在一个章节中全部涵盖。因此,我强烈建议您访问该项目的文档,如果您想继续学习。文档可以在flask-admin.readthedocs.org/上找到。
总结
在本章中,我们学习了如何使用 Flask-Admin 扩展为我们的应用程序创建管理面板。我们学习了如何将我们的 SQLAlchemy 模型公开为可编辑对象的列表,以及如何定制表格和表单的外观。我们添加了一个文件浏览器,以帮助管理应用程序的静态资产。我们还将管理面板与我们的身份验证系统集成。
在下一章中,我们将学习如何向我们的应用程序添加 API,以便可以通过编程方式访问它。
第七章:AJAX 和 RESTful API
在本章中,我们将使用 Flask-Restless 为博客应用创建一个 RESTful API。RESTful API 是以编程方式访问您的博客的一种方式,通过提供代表您的博客的高度结构化的数据。Flask-Restless 非常适用于我们的 SQLAlchemy 模型,并且还处理复杂的任务,如序列化和结果过滤。我们将使用我们的 REST API 为博客条目构建一个基于 AJAX 的评论功能。在本章结束时,您将能够为您的 SQLAlchemy 模型创建易于配置的 API,并在您的 Flask 应用中进行 AJAX 请求的创建和响应。
在本章中,我们将:
-
创建一个模型来存储博客条目上的评论
-
安装 Flask-Restless
-
为评论模型创建一个 RESTful API
-
构建一个用于使用 Ajax 与我们的 API 进行通信的前端
创建评论模型
在我们开始创建 API 之前,我们需要为我们希望共享的资源创建一个数据库模型。我们正在构建的 API 将用于使用 AJAX 创建和检索评论,因此我们的模型将包含存储未经身份验证用户在我们条目中的评论的所有相关字段。
对于我们的目的,以下字段应该足够:
-
name,发表评论的人的姓名 -
email,评论者的电子邮件地址,我们将仅使用它来显示他们在Gravatar上的图片 -
URL,评论者博客的 URL -
ip_address,评论者的 IP 地址 -
body,实际评论 -
status,其中之一是Public,Spam或Deleted -
created_timestamp,评论创建的时间戳 -
entry_id,评论相关的博客条目的 ID
让我们通过在我们的应用程序的models.py模块中创建Comment模型定义来开始编码:
class Comment(db.Model):
STATUS_PENDING_MODERATION = 0
STATUS_PUBLIC = 1
STATUS_SPAM = 8
STATUS_DELETED = 9
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64))
email = db.Column(db.String(64))
url = db.Column(db.String(100))
ip_address = db.Column(db.String(64))
body = db.Column(db.Text)
status = db.Column(db.SmallInteger, default=STATUS_PUBLIC)
created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
entry_id = db.Column(db.Integer, db.ForeignKey('entry.id'))
def __repr__(self):
return '<Comment from %r>' % (self.name,)
在添加Comment模型定义之后,我们需要设置Comment和Entry模型之间的 SQLAlchemy 关系。您会记得,我们在设置User和Entry之间的关系时曾经做过一次,通过 entries 关系。我们将通过在Entry模型中添加一个 comments 属性来为Comment做这个。
在tags关系下面,添加以下代码到Entry模型定义中:
class Entry(db.Model):
# ...
tags = db.relationship('Tag', secondary=entry_tags,
backref=db.backref('entries', lazy='dynamic'))
comments = db.relationship('Comment', backref='entry', lazy='dynamic')
我们已经指定了关系为lazy='dynamic',正如您从第五章验证用户中所记得的那样,这意味着在任何给定的Entry实例上,comments属性将是一个可过滤的查询。
创建模式迁移
为了开始使用我们的新模型,我们需要更新我们的数据库模式。使用manage.py助手,为Comment模型创建一个模式迁移:
(blog) $ python manage.py db migrate
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'comment'
Generating /home/charles/projects/blog/app/migrations/versions/490b6bc5f73c_.py ... done
然后通过运行upgrade来应用迁移:
(blog) $ python manage.py db upgrade
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.migration] Running upgrade 594ebac9ef0c -> 490b6bc5f73c, empty message
Comment模型现在已经准备好使用了!在这一点上,如果我们使用常规的 Flask 视图来实现评论,我们可能会创建一个评论蓝图并开始编写一个视图来处理评论的创建。然而,我们将使用 REST API 公开评论,并直接从前端使用 AJAX 创建它们。
安装 Flask-Restless
有了我们的模型,我们现在准备安装 Flask-Restless,这是一个第三方 Flask 扩展,可以简单地为您的 SQLAlchemy 模型构建 RESTful API。确保您已经激活了博客应用的虚拟环境后,使用pip安装 Flask-Restless:
(blog) $ pip install Flask-Restless
您可以通过打开交互式解释器并获取已安装的版本来验证扩展是否已安装。不要忘记,您的确切版本号可能会有所不同。
(blog) $ ./manage.py shell
In [1]: import flask_restless
In [2]: flask_restless.__version__
Out[2]: '0.13.0'
现在我们已经安装了 Flask-Restless,让我们配置它以使其与我们的应用程序一起工作。
设置 Flask-Restless
像其他 Flask 扩展一样,我们将从app.py模块开始,通过配置一个将管理我们新 API 的对象。在 Flask-Restless 中,这个对象称为APIManager,它将允许我们为我们的 SQLAlchemy 模型创建 RESTful 端点。将以下行添加到app.py:
# Place this import at the top of the module alongside the other extensions.
from flask.ext.restless import APIManager
# Place this line below the initialization of the app and db objects.
api = APIManager(app, flask_sqlalchemy_db=db)
因为 API 将依赖于我们的 Flask API 对象和我们的Comment模型,所以我们需要确保我们不创建任何循环模块依赖关系。我们可以通过在应用程序目录的根目录下创建一个新模块“api.py”来避免引入循环导入。
让我们从最基本的开始,看看 Flask-Restless 提供了什么。在api.py中添加以下代码:
from app import api
from models import Comment
api.create_api(Comment, methods=['GET', 'POST'])
api.py中的代码调用了我们的APIManager对象上的create_api()方法。这个方法将用额外的 URL 路由和视图代码填充我们的应用程序,这些代码一起构成了一个 RESTful API。方法参数指示我们只允许GET和POST请求(意味着评论可以被读取或创建,但不能被编辑或删除)。
最后的操作是在main.py中导入新的 API 模块,这是我们应用程序的入口点。我们导入模块纯粹是为了它的副作用,注册 URL 路由。在main.py中添加以下代码:
from app import app, db
import admin
import api
import models
import views
...
发出 API 请求
在一个终端中,启动开发服务器。在另一个终端中,让我们看看当我们向我们的 API 端点发出GET请求时会发生什么(注意没有尾随的斜杠):
$ curl 127.0.0.1:5000/api/comment
{
"num_results": 0,
"objects": [],
"page": 1,
"total_pages": 0
}
数据库中没有评论,所以没有对象被序列化和返回给我们。然而,有一些有趣的元数据告诉我们数据库中有多少对象,我们在哪一页,以及有多少总页的评论存在。
让我们通过向我们的 API POST 一些 JSON 数据来创建一个新的评论(我将假设你的数据库中的第一个条目的 id 为1)。我们将使用curl提交一个包含新评论的 JSON 编码表示的POST请求:
$ curl -X POST -H "Content-Type: application/json" -d '{
"name": "Charlie",
"email": "charlie@email.com",
"url": "http://charlesleifer.com",
"ip_address": "127.0.0.1",
"body": "Test comment!",
"entry_id": 1}' http://127.0.0.1:5000/api/comment
假设没有拼写错误,API 将以以下数据回应,确认新的Comment的创建:
{
"body": "Test comment!",
"created_timestamp": "2014-04-22T19:48:33.724118",
"email": "charlie@email.com",
"entry": {
"author_id": 1,
"body": "This is an entry about Python, my favorite programming language.",
"created_timestamp": "2014-03-06T19:50:09",
"id": 1,
"modified_timestamp": "2014-03-06T19:50:09",
"slug": "python-entry",
"status": 0,
"title": "Python Entry"
},
"entry_id": 1,
"id": 1,
"ip_address": "127.0.0.1",
"name": "Charlie",
"status": 0,
"url": "http://charlesleifer.com"
}
正如你所看到的,我们 POST 的所有数据都包含在响应中,除了其余的字段数据,比如新评论的 id 和时间戳。令人惊讶的是,甚至相应中已经序列化并包含了相应的Entry对象。
现在我们在数据库中有了一个评论,让我们尝试向我们的 API 发出另一个GET请求:
$ curl 127.0.0.1:5000/api/comment
{
"num_results": 1,
"objects": [
{
"body": "Test comment!",
"created_timestamp": "2014-04-22T19:48:33.724118",
"email": "charlie@email.com",
"entry": {
"author_id": 1,
"body": "This is an entry about Python, my favorite programming language.",
"created_timestamp": "2014-03-06T19:50:09",
"id": 1,
"modified_timestamp": "2014-03-06T19:50:09",
"slug": "python-entry",
"status": 0,
"title": "Python Entry"
},
"entry_id": 1,
"id": 1,
"ip_address": "127.0.0.1",
"name": "Charlie",
"status": 0,
"url": "http://charlesleifer.com"
}
],
"page": 1,
"total_pages": 1
}
第一个对象包含了当我们进行POST请求时返回给我们的完全相同的数据。此外,周围的元数据已经改变,以反映数据库中现在有一个评论的事实。
使用 AJAX 创建评论
为了允许用户发表评论,我们首先需要一种捕获他们输入的方法,我们将通过使用wtforms创建一个Form类来实现这一点。这个表单应该允许用户输入他们的姓名、电子邮件地址、一个可选的 URL 和他们的评论。
在条目蓝图的表单模块中,添加以下表单定义:
class CommentForm(wtforms.Form):
name = wtforms.StringField('Name', validators=[validators.DataRequired()])
email = wtforms.StringField('Email', validators=[
validators.DataRequired(),
validators.Email()])
url = wtforms.StringField('URL', validators=[
validators.Optional(),
validators.URL()])
body = wtforms.TextAreaField('Comment', validators=[
validators.DataRequired(),
validators.Length(min=10, max=3000)])
entry_id = wtforms.HiddenField(validators=[
validators.DataRequired()])
def validate(self):
if not super(CommentForm, self).validate():
return False
# Ensure that entry_id maps to a public Entry.
entry = Entry.query.filter(
(Entry.status == Entry.STATUS_PUBLIC) &
(Entry.id == self.entry_id.data)).first()
if not entry:
return False
return True
你可能会想为什么我们要指定验证器,因为 API 将处理 POST 的数据。我们这样做是因为 Flask-Restless 不提供验证,但它提供了一个我们可以执行验证的钩子。这样,我们就可以在我们的 REST API 中利用 WTForms 验证。
为了在条目详细页面使用表单,我们需要在渲染详细模板时将表单传递到上下文中。打开条目蓝图并导入新的CommentForm:
from entries.forms import EntryForm, ImageForm, CommentForm
然后修改“详细”视图,将一个表单实例传递到上下文中。我们将使用请求的条目的值预填充entry_id隐藏字段:
@entries.route('/<slug>/')
def detail(slug):
entry = get_entry_or_404(slug)
form = CommentForm(data={'entry_id': entry.id})
return render_template('entries/detail.html', entry=entry, form=form)
现在表单已经在详细模板上下文中,剩下的就是渲染表单。在entries/templates/entries/includes/中创建一个空模板,命名为comment_form.html,并添加以下代码:
{% from "macros/form_field.html" import form_field %}
<form action="/api/comment" class="form form-horizontal" id="comment-form" method="post">
{{ form_field(form.name) }}
{{ form_field(form.email) }}
{{ form_field(form.url) }}
{{ form_field(form.body) }}
{{ form.entry_id() }}
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Submit</button>
</div>
</div>
</form>
值得注意的是,我们没有使用form_field宏来处理entry_id字段。这是因为我们不希望评论表单显示一个对用户不可见的字段的标签。相反,我们将用这个值初始化表单。
最后,我们需要在detail.html模板中包含评论表单。在条目正文下面,添加以下标记:
{% block content %}
{{ entry.body }}
<h4 id="comment-form">Submit a comment</h4>
{% include "entries/includes/comment_form.html" %}
{% endblock %}
使用开发服务器,尝试导航到任何条目的详细页面。你应该会看到一个评论表单:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_07_01.jpg
AJAX 表单提交
为了简化进行 AJAX 请求,我们将使用 jQuery 库。如果你愿意,可以随意替换为其他 JavaScript 库,但是由于 jQuery 如此普遍(并且与 Bootstrap 兼容),我们将在本节中使用它。如果你一直在跟着代码进行开发,那么 jQuery 应该已经包含在所有页面中。现在我们需要创建一个 JavaScript 文件来处理评论提交。
在statics/js/中创建一个名为comments.js的新文件,并添加以下 JavaScript 代码:
Comments = window.Comments || {};
(function(exports, $) { /* Template string for rendering success or error messages. */
var alertMarkup = (
'<div class="alert alert-{class} alert-dismissable">' +
'<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' +
'<strong>{title}</strong> {body}</div>');
/* Create an alert element. */
function makeAlert(alertClass, title, body) {
var alertCopy = (alertMarkup
.replace('{class}', alertClass)
.replace('{title}', title)
.replace('{body}', body));
return $(alertCopy);
}
/* Retrieve the values from the form fields and return as an object. */
function getFormData(form) {
return {
'name': form.find('input#name').val(),
'email': form.find('input#email').val(),
'url': form.find('input#url').val(),
'body': form.find('textarea#body').val(),
'entry_id': form.find('input[name=entry_id]').val()
}
}
function bindHandler() {
/* When the comment form is submitted, serialize the form data as JSON
and POST it to the API. */
$('form#comment-form').on('submit', function() {
var form = $(this);
var formData = getFormData(form);
var request = $.ajax({
url: form.attr('action'),
type: 'POST',
data: JSON.stringify(formData),
contentType: 'application/json; charset=utf-8',
dataType: 'json'
});
request.success(function(data) {
alertDiv = makeAlert('success', 'Success', 'your comment was posted.');
form.before(alertDiv);
form[0].reset();
});
request.fail(function() {
alertDiv = makeAlert('danger', 'Error', 'your comment was not posted.');
form.before(alertDiv);
});
return false;
});
}
exports.bindHandler = bindHandler;
})(Comments, jQuery);
comments.js代码处理将表单数据序列化为 JSON 后,提交到 REST API。它还处理 API 响应,并显示成功或错误消息。
在detail.html模板中,我们只需要包含我们的脚本并绑定提交处理程序。在详细模板中添加以下块覆盖:
{% block extra_scripts %}
<script type="text/javascript" src="img/comments.js') }}"></script>
<script type="text/javascript">
$(function() {
Comments.bindHandler();
});
</script>
{% endblock %}
试着提交一两条评论。
在 API 中验证数据
不幸的是,我们的 API 没有对传入数据进行任何类型的验证。为了验证POST数据,我们需要使用 Flask-Restless 提供的一个钩子。Flask-Restless 将这些钩子称为请求预处理器和后处理器。
让我们看看如何使用 POST 预处理器对评论数据进行一些验证。首先打开api.py并进行以下更改:
from flask.ext.restless import ProcessingException
from app import api
from entries.forms import CommentForm
from models import Comment
def post_preprocessor(data, **kwargs):
form = CommentForm(data=data)
if form.validate():
return form.data
else:
raise ProcessingException(
description='Invalid form submission.',
code=400)
api.create_api(
Comment,
methods=['GET', 'POST'],
preprocessors={
'POST': [post_preprocessor],
})
我们的 API 现在将使用来自CommentForm的验证逻辑来验证提交的评论。我们通过为POST方法指定一个预处理器来实现这一点。我们已经实现了post_preprocessor作为POST预处理器,它接受反序列化的POST数据作为参数。然后我们可以将这些数据传递给我们的CommentForm并调用它的validate()方法。如果验证失败,我们将引发一个ProcessingException,向 Flask-Restless 发出信号,表明这些数据无法处理,并返回一个400 Bad Request 响应。
在下面的截图中,我没有提供必需的评论字段。当我尝试提交评论时,我收到了一个错误消息:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_07_02.jpg
预处理器和后处理器
我们刚刚看了一个使用 Flask-Restless 的POST方法预处理器的示例。在下表中,你可以看到其他可用的钩子:
| 方法名称 | 描述 | 预处理器参数 | 后处理器参数 |
|---|---|---|---|
GET_SINGLE | 通过主键检索单个对象 | instance_id,对象的主键 | result,对象的字典表示 |
GET_MANY | 检索多个对象 | search_params,用于过滤结果集的搜索参数字典 | result,对象的search_params表示 |
PUT_SINGLE | 通过主键更新单个对象 | instance_id数据,用于更新对象的数据字典 | result,更新后对象的字典表示 |
PUT_MANY | 更新多个对象 | search_params,用于确定要更新哪些对象的搜索参数字典。data,用于更新对象的数据字典。 | query,表示要更新的对象的 SQLAlchemy 查询。data``search_params |
POST | 创建新实例 | data,用于填充新对象的数据字典 | result,新对象的字典表示 |
DELETE | 通过主键删除实例 | instance_id,要删除的对象的主键 | was_deleted,一个布尔值,指示对象是否已被删除 |
使用 AJAX 加载评论
现在我们能够使用 AJAX 创建经过验证的评论,让我们使用 API 来检索评论列表,并在博客条目下方显示它们。为此,我们将从 API 中读取值,并动态创建 DOM 元素来显示评论。您可能还记得我们之前检查的 API 响应中返回了相当多的私人信息,包括每条评论相关联的整个序列化表示的Entry。对于我们的目的来说,这些信息是多余的,而且还会浪费带宽。
让我们首先对评论端点进行一些额外的配置,以限制我们返回的Comment字段。在api.py中,对api.create_api()的调用进行以下添加:
api.create_api(
Comment,
include_columns=['id', 'name', 'url', 'body', 'created_timestamp'],
methods=['GET', 'POST'],
preprocessors={
'POST': [post_preprocessor],
})
现在请求评论列表会给我们一个更易管理的响应,不会泄露实现细节或私人数据:
$ curl http://127.0.0.1:5000/api/comment
{
"num_results": 1,
"objects": [
{
"body": "Test comment!",
"created_timestamp": "2014-04-22T19:48:33.724118",
"name": "Charlie",
"url": "http://charlesleifer.com"
}
],
"page": 1,
"total_pages": 1
}
一个很好的功能是在用户的评论旁边显示一个头像。Gravatar 是一个免费的头像服务,允许用户将他们的电子邮件地址与图像关联起来。我们将使用评论者的电子邮件地址来显示他们关联的头像(如果存在)。如果用户没有创建头像,将显示一个抽象图案。
让我们在Comment模型上添加一个方法来生成用户 Gravatar 图像的 URL。打开models.py并向Comment添加以下方法:
def gravatar(self, size=75):
return 'http://www.gravatar.com/avatar.php?%s' % urllib.urlencode({
'gravatar_id': hashlib.md5(self.email).hexdigest(),
'size': str(size)})
您还需要确保在模型模块的顶部导入hashlib和urllib。
如果我们尝试在列的列表中包括 Gravatar,Flask-Restless 会引发异常,因为gravatar实际上是一个方法。幸运的是,Flask-Restless 提供了一种在序列化对象时包含方法调用结果的方法。在api.py中,对create_api()的调用进行以下添加:
api.create_api(
Comment,
include_columns=['id', 'name', 'url', 'body', 'created_timestamp'],
include_methods=['gravatar'],
methods=['GET', 'POST'],#, 'DELETE'],
preprocessors={
'POST': [post_preprocessor],
})
继续尝试获取评论列表。现在你应该看到 Gravatar URL 包含在序列化响应中。
检索评论列表
现在我们需要返回到我们的 JavaScript 文件,并添加代码来检索评论列表。我们将通过向 API 传递搜索过滤器来实现这一点,API 将仅检索与请求的博客条目相关联的评论。搜索查询被表示为一系列过滤器,每个过滤器指定以下内容:
-
列的名称
-
操作(例如,等于)
-
要搜索的值
打开comments.js并在以下行之后添加以下代码:
(function(exports, $) {:
function displayNoComments() {
noComments = $('<h3>', {
'text': 'No comments have been posted yet.'});
$('h4#comment-form').before(noComments);
}
/* Template string for rendering a comment. */
var commentTemplate = (
'<div class="media">' +
'<a class="pull-left" href="{url}">' +
'<img class="media-object" src="img/{gravatar}" />' +
'</a>' +
'<div class="media-body">' +
'<h4 class="media-heading">{created_timestamp}</h4>{body}' +
'</div></div>'
);
function renderComment(comment) {
var createdDate = new Date(comment.created_timestamp).toDateString();
return (commentTemplate
.replace('{url}', comment.url)
.replace('{gravatar}', comment.gravatar)
.replace('{created_timestamp}', createdDate)
.replace('{body}', comment.body));
}
function displayComments(comments) {
$.each(comments, function(idx, comment) {
var commentMarkup = renderComment(comment);
$('h4#comment-form').before($(commentMarkup));
});
}
function load(entryId) {
var filters = [{
'name': 'entry_id',
'op': 'eq',
'val': entryId}];
var serializedQuery = JSON.stringify({'filters': filters});
$.get('/api/comment', {'q': serializedQuery}, function(data) {
if (data['num_results'] === 0) {
displayNoComments();
} else {
displayComments(data['objects']);
}
});
}
然后,在文件底部附近,导出load函数以及bindHandler导出,如下所示:
exports.load = load;
exports.bindHandler = bindHandler;
我们添加的新 JavaScript 代码会向 API 发出 AJAX 请求,以获取与给定条目相关联的评论。如果没有评论存在,将显示一条消息,指示尚未发表评论。否则,条目将作为列表呈现在Entry正文下方。
最后的任务是在页面呈现时在详细模板中调用Comments.load()。打开detail.html并添加以下突出显示的代码:
<script type="text/javascript">
$(function() {
Comments.load({{ entry.id }});
Comments.bindHandler();
});
</script>
在发表了一些评论之后,评论列表看起来如下图所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_07_03.jpg
作为练习,看看你是否能够编写代码来呈现用户发表的任何新评论。您会记得,当成功创建评论时,新数据将作为 JSON 对象返回。
阅读更多
Flask-Restless 支持许多配置选项,由于篇幅原因,本章未能涵盖。搜索过滤器是一个非常强大的工具,我们只是触及了可能性的表面。此外,预处理和后处理钩子可以用于实现许多有趣的功能,例如以下功能:
-
可以在预处理器中实现的身份验证
-
GET_MANY的默认过滤器,可以用于限制评论列表,例如只显示公开的评论 -
向序列化响应添加自定义或计算值
-
修改传入的
POST值以在模型实例上设置默认值
如果 REST API 是您的应用程序中的关键组件,我强烈建议花时间阅读 Flask-Restless 文档。文档可以在网上找到:flask-restless.readthedocs.org/en/latest/。
总结
在本章中,我们使用 Flask-Restless 扩展为我们的应用程序添加了一个简单的 REST API。然后,我们使用 JavaScript 和 Ajax 将我们的前端与 API 集成,允许用户查看和发布新评论,而无需编写一行视图代码。
在下一章中,我们将致力于创建可测试的应用程序,并找到改进我们代码的方法。这也将使我们能够验证我们编写的代码是否按照我们的意愿进行操作;不多,也不少。自动化这一过程将使您更有信心,并确保 RESTful API 按预期工作。
第八章:测试 Flask 应用
在本章中,我们将学习如何编写覆盖博客应用程序所有部分的单元测试。我们将利用 Flask 的测试客户端来模拟实时请求,并了解 Mock 库如何简化测试复杂交互,比如调用数据库等第三方服务。
在本章中,我们将学习以下主题:
-
Python 的单元测试模块和测试编写的一般指导
-
友好的测试配置
-
如何使用 Flask 测试客户端模拟请求和会话
-
如何使用 Mock 库测试复杂交互
-
记录异常和错误邮件
单元测试
单元测试是一个让我们对代码、bug 修复和未来功能有信心的过程。单元测试的理念很简单;你编写与你的功能代码相辅相成的代码。
举个例子,假设我们设计了一个需要正确计算一些数学的程序;你怎么知道它成功了?为什么不拿出一个计算器,你知道计算机是什么吗?一个大计算器。此外,计算机在乏味的重复任务上确实非常擅长,那么为什么不编写一个单元测试来为你计算出答案呢?对代码的所有部分重复这种模式,将这些测试捆绑在一起,你就对自己编写的代码完全有信心了。
注意
有人说测试是代码“味道”的标志,你的代码如此复杂,以至于需要测试来证明它的工作。这意味着代码应该更简单。然而,这真的取决于你的情况,你需要自己做出判断。在我们开始简化代码之前,单元测试是一个很好的起点。
单元测试的巧妙之处在于测试与功能代码相辅相成。这些方法证明了测试的有效性,而测试证明了方法的有效性。它减少了代码出现重大功能错误的可能性,减少了将来重新编写代码的头痛,并允许你专注于你想要处理的新功能的细枝末节。
提示
单元测试的理念是验证代码的小部分,或者说是测试简单的功能部分。这将构建成应用程序的整体。很容易写出大量测试代码,测试的是代码的功能而不是代码本身。如果你的测试看起来很大,通常表明你的主要代码应该被分解成更小的方法。
Python 的单元测试模块
幸运的是,几乎总是如此,Python 有一个内置的单元测试模块。就像 Flask 一样,很容易放置一个简单的单元测试模块。在你的主要博客应用程序中,创建一个名为tests的新目录,并在该目录中创建一个名为test.py的新文件。现在,使用你喜欢的文本编辑器,输入以下代码:
import unittest
class ExampleTest(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_some_functionality(self):
pass
def test_some_other_functionality(self):
pass
if __name__ == "__main__":
unittest.main()
前面的片段演示了我们将编写的所有单元测试模块的基本框架。它简单地利用内置的 Python 模块unittest,然后创建一个包装特定测试集的类。在这个例子中,测试是以单词test开头的方法。单元测试模块将这些方法识别为每次调用unittest.main时应该运行的方法。此外,TestCase类(ExampleTest类在这里继承自它)具有一些特殊方法,单元测试将始终尝试使用。其中之一是setUp,这是在运行每个测试方法之前运行的方法。当您想要在隔离环境中运行每个测试,但是,例如,要在数据库中建立连接时,这可能特别有用。
另一个特殊的方法是tearDown。每次运行测试方法时都会运行此方法。同样,当我们想要维护数据库时,这对于每个测试都在隔离环境中运行非常有用。
显然,这个代码示例如果运行将不会做任何事情。要使其处于可用状态,并且遵循测试驱动开发(TDD)的原则,我们首先需要编写一个测试,验证我们即将编写的代码是否正确,然后编写满足该测试的代码。
一个简单的数学测试
在这个示例中,我们将编写一个测试,验证一个方法将接受两个数字作为参数,从第二个参数中减去一个,然后将它们相乘。看一下以下示例:
| 参数 1 | 参数 2 | 答案 |
|---|---|---|
1 | 1 | 1 * (1-1) = 0 |
1 | 2 | 1 * (2-1) = 1 |
2 | 3 | 2 * (3-1) = 4 |
在你的test.py文件中,你可以创建一个在ExampleTest类中表示前面表格的方法,如下所示:
def test_minus_one_multiplication(self):
self.assertEqual(my_multiplication(1,1), 0)
self.assertEqual(my_multiplication(1,2), 1)
self.assertEqual(my_multiplication(2,3), 4)
self.assertNotEqual(my_multiplication(2,2), 3)
前面的代码创建了一个新的方法,使用 Python 的unittest模块来断言问题的答案。assertEqual函数将my_multiplication方法返回的响应作为第一个参数,并将其与第二个参数进行比较。如果通过了,它将什么也不做,等待下一个断言进行测试。但如果不匹配,它将抛出一个错误,并且你的测试方法将停止执行,告诉你出现了错误。
在前面的代码示例中,还有一个assertNotEqual方法。它的工作方式与assertEqual类似,但是检查值是否不匹配。还有一个好主意是检查你的方法何时可能失败。如果你只检查了方法将起作用的情况,那么你只完成了一半的工作,并且可能会在边缘情况下遇到问题。Python 的unittest模块提供了各种各样的断言方法,这将是有用的去探索。
现在我们可以编写将给出这些结果的方法。为简单起见,我们将在同一个文件中编写该方法。在文件中,创建以下方法:
def my_multiplication(value1, value2):
return value1 * value2 – 1
保存文件并使用以下命令运行它:
python test.py
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_08_01.jpg
哎呀!它失败了。为什么?嗯,回顾my_multiplication方法发现我们漏掉了一些括号。让我们回去纠正一下:
def my_multiplication(value1, value2):
return value1 * (value2 – 1)
现在让我们再次运行它:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_08_02.jpg
成功了!现在我们有了一个正确的方法;将来,我们将知道它是否被更改过,以及在以后需要如何更改。现在来用这个新技能与 Flask 一起使用。
Flask 和单元测试
你可能会想:“单元测试对于代码的小部分看起来很棒,但是如何为整个 Flask 应用程序进行测试呢?”嗯,正如之前提到的一种方法是确保所有的方法尽可能离散——也就是说,确保你的方法尽可能少地完成它们的功能,并避免方法之间的重复。如果你的方法不是离散的,现在是整理它们的好时机。
另一件有用的事情是,Flask 已经准备好进行单元测试。任何现有应用程序都有可能至少可以应用一些单元测试。特别是,任何 API 区域,例如无法验证的区域,都可以通过利用 Flask 中已有的代表 HTTP 请求的方法来进行极其容易的测试。以下是一个简单的示例:
import unittest
from flask import request
from main import app
class AppTest(unittest.TestCase):
def setUp(self):
self.app = app.test_client()
def test_homepage_works(self):
response = self.app.get("/")
self.assertEqual(response.status_code, 200)
if __name__ == "__main__":
unittest.main()
这段代码应该看起来非常熟悉。它只是重新编写了前面的示例,以验证主页是否正常工作。Flask 公开的test_client方法允许通过代表 HTTP 调用的方法简单访问应用程序,就像test方法的第一行所示。test方法本身并不检查页面的内容,而只是检查页面是否成功加载。这可能听起来微不足道,但知道主页是否正常工作是很有用的。结果呢?你可以在这里看到:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_08_03.jpg
提示
需要注意的一件事是,我们不需要测试 Flask 本身,必须避免测试它,以免为自己创造太多工作。
测试一个页面
关于运行先前的测试的一件事需要注意的是,它们非常简单。实际上没有浏览器会以这种方式行事。浏览器会执行诸如存储用于登录的 cookie、请求 JavaScript、图像和 CSS 文件等静态文件,以及请求特定格式的数据等操作。不知何故,我们需要模拟这种功能,并测试结果是否正确。
提示
这是单元测试开始变成功能测试的部分。虽然这本身并没有什么错,但值得注意的是,较小的测试更好。
幸运的是,Flask 通过使用先前的app.get方法来为您完成所有这些工作,但是您可以使用一些技巧来使事情变得更容易。例如,向TestCase类添加登录和退出功能将使事情变得简单得多:
LOGIN_URL = "/login/"
LOGOUT_URL = "/logout/"
def login (self, email, password):
return self.app.post(self.LOGIN_URL, data={
"email": email,
"password": password
}, follow_redirects=True)
前面的代码是未来测试用例的框架。每当我们有一个需要登录和退出的测试用例时,只需将此Mixin添加到继承列表中,它就会自动可用:
class ExampleFlaskTest(unittest.TestCase, FlaskLoginMixin):
def setUp(self):
self.app = app.test_client()
def test_login(self):
response = self.login("admin", "password")
self.assertEqual(response.status_code, 200)
self.assertTrue("Success" in response.data)
def test_failed_login(self):
response = self.login("admin", "PASSWORD")
self.assertEqual(response.status_code, 200)
self.assertTrue("Invalid" in response.data)
def test_logout(self):
response = self.logout()
self.assertEqual(response.status_code, 200)
self.assertTrue("logged out" in response.data)
我们刚刚解释的测试用例使用了FlaskLoginMixin,这是一组方法,可以帮助检查登录和退出是否正常工作。这是通过检查响应页面是否发送了正确的消息,并且页面内容中是否有正确的警告来实现的。我们的测试还可以进一步扩展,以检查用户是否可以访问他们不应该访问的页面。Flask 会为您处理会话和 cookie,所以只需使用以下代码片段即可:
class ExampleFlaskTest(unittest.TestCase, FlaskLoginMixin):
def setUp(self):
self.app = app.test_client()
def test_admin_can_get_to_admin_page(self):
self.login("admin", "password")
response = self.app.get("/admin/")
self.assertEqual(response.status_code, 200)
self.assertTrue("Hello" in response.data)
def test_non_logged_in_user_can_get_to_admin_page(self):
response = self.app.get("/admin/")
self.assertEqual(response.status_code, 302)
self.assertTrue("redirected" in response.data)
def test_normal_user_cannot_get_to_admin_page(self):
self.login("user", "password")
response = self.app.get("/admin/")
self.assertEqual(response.status_code, 302)
self.assertTrue("redirected" in response.data)
def test_logging_out_prevents_access_to_admin_page(self):
self.login("admin", "password")
self.logout()
response = self.app.get("/admin/")
self.assertEqual(response.status_code, 302)
self.assertTrue("redirected" in response.data)
前面的代码片段显示了如何测试某些页面是否受到正确保护。这是一个非常有用的测试。它还验证了,当管理员注销时,他们将无法再访问他们在登录时可以访问的页面。方法名称是自解释的,因此如果这些测试失败,很明显可以知道正在测试什么。
测试 API
测试 API 甚至更容易,因为它是程序干预。使用第七章中设置的先前评论 API,AJAX 和 RESTful API,我们可以很容易地插入和检索一些评论,并验证它是否正常工作。为了测试这一点,我们需要import json 库来处理我们的基于JSON的 API:
class ExampleFlaskAPITest(unittest.TestCase, FlaskLoginMixin):
def setUp(self):
self.app = app.test_client()
self.comment_data = {
"name": "admin",
"email": "admin@example.com",
"url": "http://localhost",
"ip_address": "127.0.0.1",
"body": "test comment!",
"entry_id": 1
}
def test_adding_comment(self):
self.login("admin", "password")
data=json.dumps(self.comment_data), content_type="application/json")
self.assertEqual(response.status_code, 200)
self.assertTrue("body" in response.data)
self.assertEqual(json.loads(response.data)['body'], self.comment_data["body"])
def test_getting_comment(self):
result = self.app.post("/api/comment",
data=json.dumps(self.comment_data), content_type="application/json")
response = self.app.get("/api/comment")
self.assertEqual(response.status_code, 200)
self.assertTrue(json.loads(result.data) in json.loads(response.data)['objects'])
前面的代码示例显示了创建一个评论字典对象。这用于验证输入的值与输出的值是否相同。因此,这些方法测试将评论数据发布到/api/comment端点,验证服务器返回的数据是否正确。test_getting_comment方法再次检查是否将评论发布到服务器,但更关心所请求的结果,通过验证发送的数据是否与输出的数据相同。
测试友好的配置
在团队中编写测试或在生产环境中编写测试时遇到的第一个障碍之一是,我们如何确保测试在不干扰生产甚至开发数据库的情况下运行。您肯定不希望尝试修复错误或试验新功能,然后发现它所依赖的数据已经发生了变化。有时,只需要在本地数据库的副本上运行一个快速测试,而不受任何其他人的干扰,Flask 应用程序知道如何使用它。
Flask 内置的一个功能是根据环境变量加载配置文件。
app.config.from_envvar('FLASK_APP_BLOG_CONFIG_FILE')
前面的方法调用通知您的 Flask 应用程序应该加载在环境变量FLASK_APP_BLOG_CONFIG_FILE中指定的文件中的配置。这必须是要加载的文件的绝对路径。因此,当您运行测试时,应该在这里引用一个特定于运行测试的文件。
由于我们已经为我们的环境设置了一个配置文件,并且正在创建一个测试配置文件,一个有用的技巧是利用现有的配置并覆盖重要的部分。首先要做的是创建一个带有 init.py 文件的 config 目录。然后可以将我们的 testing.py 配置文件添加到该目录中,并覆盖 config.py 配置文件的一些方面。例如,你的新测试配置文件可能如下所示:
TESTING=True
DATABASE="sqlite://
上面的代码添加了 TESTING 属性,可以用来确定你的应用程序当前是否正在进行测试,并将 DATABASE 值更改为更适合测试的数据库,一个内存中的 SQLite 数据库,不必在测试结束后清除。
然后这些值可以像 Flask 中的任何其他配置一样使用,并且在运行测试时,可以指定环境变量指向该文件。如果我们想要自动更新测试的环境变量,我们可以在test文件夹中的test.py文件中更新 Python 的内置 OS 环境变量对象:
import os
os.environ['FLASK_APP_BLOG_CONFIG_FILE'] = os.path.join(os.getcwd(), "config", "testing.py")
模拟对象
模拟是测试人员工具箱中非常有用的一部分。模拟允许自定义对象被一个对象覆盖,该对象可以用来验证方法对其参数是否执行正确的操作。有时,这可能需要重新构想和重构你的应用程序,以便以可测试的方式工作,但是概念很简单。我们创建一个模拟对象,将其运行通过方法,然后对该对象运行测试。它特别适用于数据库和 ORM 模型,比如SQLAlchemy。
有很多模拟框架可用,但是在本书中,我们将使用Mockito:
pip install mockito
这是最简单的之一:
>>> from mockito import *
>>> mock_object = mock()
>>> mock_object.example()
>>> verify(mock_object).example()
True
上面的代码从Mockito库导入函数,创建一个可以用于模拟的mock对象,对其运行一个方法,并验证该方法已经运行。显然,如果你希望被测试的方法在没有错误的情况下正常运行,你需要在调用模拟对象上的方法时返回一个有效的值。
>>> duck = mock()
>>> when(duck).quack().thenReturn("quack")
>>> duck.quack()
"quack"
在上面的例子中,我们创建了一个模拟的duck对象,赋予它quack的能力,然后证明它可以quack。
注意
在 Python 这样的动态类型语言中,当你拥有的对象可能不是你期望的对象时,使用鸭子类型是一种常见的做法。正如这句话所说“如果它走起来像鸭子,叫起来像鸭子,那它一定是鸭子”。这在创建模拟对象时非常有用,因为很容易使用一个假的模拟对象而不让你的方法注意到切换。
当 Flask 使用其装饰器在你的方法运行之前运行方法,并且你需要覆盖它,例如,替换数据库初始化程序时,就会出现困难。这里可以使用的技术是让装饰器运行一个对模块全局可用的方法,比如创建一个连接到数据库的方法。
假设你的app.py看起来像下面这样:
from flask import Flask, g
app = Flask("example")
def get_db():
return {}
@app.before_request
def setup_db():
g.db = get_db()
@app.route("/")
def homepage():
return g.db.get("foo")
上面的代码设置了一个非常简单的应用程序,创建了一个 Python 字典对象作为一个虚假的数据库。现在要覆盖为我们自己的数据库如下:
from mockito import *
import unittest
import app
class FlaskExampleTest(unittest.TestCase):
def setUp(self):
self.app = app.app.test_client()
self.db = mock()
def get_fake_db():
return self.db
app.get_db = get_fake_db
def test_before_request_override(self):
when(self.db).get("foo").thenReturn("123")
response = self.app.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, "123")
if __name__ == "__main__":
unittest.main()
上面的代码使用 Mockito 库创建一个虚假的数据库对象。它还创建了一个方法,覆盖了 app 模块中创建数据库连接的方法,这里是一个简单的字典对象。你会注意到,当使用 Mockito 时,你也可以指定方法的参数。现在当测试运行时,它会向数据库插入一个值,以便页面返回;然后进行测试。
记录和错误报告
记录和错误报告对于一个生产就绪的网络应用来说是内在的。即使你的应用程序崩溃,记录仍然会记录所有问题,而错误报告可以直接通知我们特定的问题,即使网站仍在运行。
在任何人报告错误之前发现错误可能是非常令人满意的。这也使得您能够在用户开始向您抱怨之前推出修复。然而,为了做到这一点,您需要知道这些错误是什么,它们是在什么时候发生的,以及是什么导致了它们。
幸运的是,现在您应该非常熟悉,Python 和 Flask 已经掌握了这一点。
日志记录
Flask 自带一个内置的记录器——Python 内置记录器的一个已定义实例。你现在应该对它非常熟悉了。默认情况下,每次访问页面时都会显示记录器消息。
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_08_04.jpg
前面的屏幕截图显然显示了终端的输出。我们可以在这里看到有人在特定日期从localhost(127.0.0.1)访问了根页面,使用了GET请求,以及其他一些目录。服务器响应了一个“200成功”消息和两个“404未找到错误”消息。虽然在开发时拥有这个终端输出是有用的,但如果您的应用程序在生产环境中运行时崩溃,这并不一定很有用。我们需要从写入的文件中查看发生了什么。
记录到文件
有各种各样依赖于操作系统的将这样的日志写入文件的方法。然而,如前所述,Python 已经内置了这个功能,Flask 只是遵循 Python 的计划,这是非常简单的。将以下内容添加到app.py文件中:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler('blog.log')
app.logger.addHandler(file_handler)
需要注意的一点是,记录器使用不同的处理程序来完成其功能。我们在这里使用的处理程序是RotatingFileHandler。这个处理程序不仅会将文件写入磁盘(在这种情况下是blog.log),还会确保我们的文件不会变得太大并填满磁盘,潜在地导致网站崩溃。
自定义日志消息
在尝试调试难以追踪的问题时,一个非常有用的事情是我们可以向我们的博客应用程序添加更多的日志记录。这可以通过 Flask 内置的日志对象来实现,如下所示:
@app.route("/")
def homepage():
app.logger.info("Homepage has been accessed.")
前面的示例演示了如何创建自定义日志消息。然而,这样的消息实际上会相当大幅地减慢我们的应用程序,因为它会在每次访问主页时将该消息写入文件或控制台。幸运的是,Flask 也理解日志级别的概念,我们可以指定在不同环境中应记录哪些消息。例如,在生产环境中记录信息消息是没有用的,而用户登录失败则值得记录。
app.logger.warning("'{user}' failed to login successfully.".format(user=user))
前面的命令只是记录了一个警告,即用户未能成功登录,使用了 Python 的字符串格式化方法。只要 Python 中的错误日志记录足够低,这条消息就会被显示。
级别
日志级别的原则是:日志的重要性越高,级别越高,根据您的日志级别,记录的可能性就越小。例如,要能够记录警告(以及以上级别,如ERROR),我们需要将日志级别调整为WARNING。我们可以在配置文件中进行这样的调整。编辑config文件夹中的config.py文件,添加以下内容:
import logging
LOG_LEVEL=logging.WARNING
Now in your app.py add the line:
app.logger.setLevel(config['LOG_LEVEL'])
前面的代码片段只是使用内置的 Python 记录器告诉 Flask 如何处理日志。当然,您可以根据您的环境设置不同的日志级别。例如,在config文件夹中的testing.py文件中,我们应该使用以下内容:
LOG_LEVEL=logging.ERROR
至于测试的目的,我们不需要警告。同样,我们应该为任何生产配置文件做同样的处理;对于任何开发配置文件,使用样式。
错误报告
在机器上记录错误是很好的,但如果错误直接发送到您的收件箱,您可以立即收到通知,那就更好了。幸运的是,像所有这些东西一样,Python 有一种内置的方法可以做到这一点,Flask 可以利用它。这只是另一个处理程序,比如RotatingFileHandler。
from logging.handlers import SMTPHandler
email_handler = SMTPHandler("127.0.0.1", "admin@localhost", app.config['ADMIN_EMAILS'], "{appname} error".format(appname=app.name))
app.logger.addHandler(email_handler)
前面的代码创建了一个SMTPHandler,其中配置了邮件服务器的位置和发送地址,从配置文件中获取要发送邮件的电子邮件地址列表,并为邮件设置了主题,以便我们可以确定错误的来源。
阅读更多
单元测试是一个广阔而复杂的领域。Flask 在其他编写有效测试的技术方面有一些很好的文档:flask.pocoo.org/docs/0.10/testing/。
当然,Python 有自己的单元测试文档:docs.python.org/2/library/unittest.html。
Flask 使用 Python 的日志模块进行日志记录。这又遵循了 C 库结构的日志记录级别。更多细节可以在这里找到:docs.python.org/2/library/logging.html。
总结
在本章中,我们已经学会了如何为我们的博客应用创建一些测试,以验证它是否正确加载页面,以及登录是否正确进行。我们还设置了将日志记录到文件,并在发生错误时发送电子邮件。
在下一章中,我们将学习如何通过扩展来改进我们的博客,这些扩展可以在我们的部分付出最小的努力的情况下添加额外的功能。
第九章:优秀的扩展
在本章中,我们将学习如何通过一些流行的第三方扩展增强我们的 Flask 安装。扩展允许我们以非常少的工作量添加额外的安全性或功能,并可以很好地完善您的博客应用程序。我们将研究跨站点请求伪造(CSRF)保护您的表单,Atom 订阅源以便其他人可以找到您的博客更新,为您使用的代码添加语法高亮,减少渲染模板时的负载的缓存,以及异步任务,以便您的应用程序在进行密集操作时不会变得无响应。
在本章中,我们将学习以下内容:
-
使用 Flask-SeaSurf 进行 CSRF 保护
-
使用 werkzeug.contrib 生成 Atom 订阅源
-
使用 Pygments 进行语法高亮
-
使用 Flask-Cache 和 Redis 进行缓存
-
使用 Celery 进行异步任务执行
SeaSurf 和表单的 CSRF 保护
CSRF 保护通过证明 POST 提交来自您的站点,而不是来自另一个站点上精心制作的恶意利用您博客上的 POST 端点的网络表单,为您的站点增加了安全性。这些恶意请求甚至可以绕过身份验证,如果您的浏览器仍然认为您已登录。
我们避免这种情况的方法是为站点上的任何表单添加一个特殊的隐藏字段,其中包含由服务器生成的值。当提交表单时,可以检查特殊字段中的值是否与服务器生成的值匹配,如果匹配,我们可以继续提交表单。如果值不匹配或不存在,则表单来自无效来源。
注意
CSRF 保护实际上证明了包含 CSRF 字段的模板用于生成表单。这可以减轻来自其他站点的最基本的 CSRF 攻击,但不能确定表单提交只来自我们的服务器。例如,脚本仍然可以屏幕抓取页面的内容。
现在,自己构建 CSRF 保护并不难,而且通常用于生成我们的表单的 WTForms 已经内置了这个功能。但是,让我们来看看 SeaSurf:
pip install flask-seasurf
安装 SeaSurf 并使用 WTForms 后,将其集成到我们的应用程序中现在变得非常容易。打开您的app.py文件并添加以下内容:
from flask.ext.seasurf import SeaSurf
csrf = SeaSurf(app)
这只是为您的应用程序启用了 SeaSurf。现在,要在您的表单中启用 CSRF,请打开forms.py并创建以下 Mixin:
from flask.ext.wtf import HiddenField
import g
from app import app
class CSRFMixin(object):
@staticmethod
@app.before_request
def add_csrf():
self._csrf_token = HiddenField(default=g._csrf_token)
上述代码创建了一个简单的 CSRF Mixin,可以选择在所有表单中使用。装饰器确保在请求之前运行该方法,以便向您的表单添加具有随机生成的 CSRF 令牌值的HiddenField字段。要在您的表单中使用此 Mixin,在这种情况下是您的登录表单,更新类如下:
class LoginForm(Form, CSRFMixin):
就是这样。我们需要对所有要保护的表单进行这些更改,通常是所有表单。
创建 Atom 订阅源
任何博客都非常有用的一个功能是让读者能够及时了解最新内容。这通常是通过 RSS 阅读器客户端来实现的,它会轮询您的 RSS 订阅源。虽然 RSS 被广泛使用,但更好、更成熟的订阅格式是可用的,称为 Atom。
这两个文件都可以由客户端请求,并且是标准和简单的 XML 数据结构。幸运的是,Flask 内置了 Atom 订阅源生成器;或者更具体地说,Flask 使用的 WSGI 接口中内置了一个贡献的模块,称为 Werkzeug。
让它运行起来很简单,我们只需要从数据库中获取最近发布的帖子。最好为此创建一个新的 Blueprint;但是,您也可以在main.py中完成。我们只需要利用一些额外的模块:
from urlparse import urljoin
from flask import request, url_for
from werkzeug.contrib.atom import AtomFeed
from models import Entry
并创建一个新的路由:
@app.route('/latest.atom')
def recent_feed():
feed = AtomFeed(
'Latest Blog Posts',
feed_url=request.url,
url=request.url_root,
author=request.url_root
)
entries = EntrY.query.filter(Entry.status == Entry.STATUS_PUBLIC).order_by(EntrY.created_timestamp.desc()).limit(15).all()
for entry in entries:
feed.add(
entry.title,
entry.body,
content_type='html',
url=urljoin(request.url_root, url_for("entries.detail", slug=entry.slug) ),
updated=entry.modified_timestamp,
published=entry.created_timestamp
)
return feed.get_response()
现在运行您的 Flask 应用程序,Atom 订阅源将可以从http://127.0.0.1:5000/latest.atom访问
使用 Pygments 进行语法高亮
通常,作为编码人员,我们希望能够在网页上显示代码,虽然不使用语法高亮显示阅读代码是一种技能,但一些颜色可以使阅读体验更加愉快。
与 Python 一样,已经有一个模块可以为您完成这项工作,当然,您可以通过以下命令轻松安装它:
pip install Pygments
注意
Pygments 仅适用于已知的代码部分。因此,如果您想显示代码片段,我们可以这样做。但是,如果您想突出显示代码的内联部分,我们要么遵循 Markdown 的下一节,要么需要使用一些在线 Javascript,例如highlight.js。
要创建代码片段,我们需要首先创建一个新的蓝图。让我们创建一个名为snippets的目录,然后创建一个__init__.py文件,接着创建一个名为blueprint.py的文件,其中包含以下代码:
from flask import Blueprint, request, render_template, redirect, url_for
from helpers import object_list
from app import db, app
from models import Snippet
from forms import SnippetForm
from pygments import highlight
from pygments.lexers import PythonLexer
from pygments.formatters import HtmlFormatter
snippets = Blueprint('snippets', __name__, template_folder='templates')
@app.template_filter('pygments')
def pygments_filter(code):
return highlight(code, PythonLexer(), HtmlFormatter())
@snippets.route('/')
def index():
snippets = Snippet.query.order_by(Snippet.created_timestamp.desc())
return object_list('entries/index.html', snippets)
@snippets.route('/<slug>/')
def detail(slug):
snippet = Snippet.query.filter(Snippet .slug == slug).first_or_404()
return render_template('snippets/detail.html', entry=snippet)
@snippets.route('/create/', methods=['GET', 'POST'])
def create():
if request.method == 'POST':
form = SnippetForm(request.form)
if form.validate():
snippet = form.save_entry(Snippet())
db.session.add(snippet)
db.session.commit()
return redirect(url_for('snippets.detail', slug=snippet.slug))
else:
form = SnippetForm()
return render_template('snippets/create.html', form=form)
@snippets.route('/<slug>/edit/', methods=['GET', 'POST'])
def edit(slug):
snippet = Snippet.query.filter(Snippet.slug == slug).first_or_404()
if request.method == 'POST':
form = SnippetForm(request.form, obj=snippet)
if form.validate():
snippet = form.save_entry(snippet)
db.session.add(snippet)
db.session.commit()
return redirect(url_for('snippets.detail', slug=entry.slug))
else:
form = EntryForm(obj=entry)
return render_template('entries/edit.html', entry=snippet, form=form)
在前面的示例中,我们设置了 Pygments 模板过滤器,允许将一串代码转换为 HTML 代码。我们还巧妙地利用了完全适合我们需求的条目模板。我们使用我们自己的detail.html,因为那里是 Pygments 发生魔法的地方。我们需要在 snippets 目录中创建一个 templates 目录,然后在 templates 中创建一个名为 snippets 的目录,这是我们存储 detail.html 的地方。因此,现在我们的目录结构看起来像 app/snippets/templates/snipperts/detail.html 现在让我们设置该文件,如下所示:
{% extends "base.html" %}
{% block title %}{{ entry.title }} - Snippets{% endblock %}
{% block content_title %}Snippet{% endblock %}
{% block content %}
{{ entry.body | pygments | safe}}
{% endblock %}
这基本上与我们在书中早期使用的detail.html相同,只是现在我们通过我们在应用程序中创建的 Pygments 过滤器传递它。由于我们早期使用的模板过滤器生成原始 HTML,我们还需要将其输出标记为安全。
我们还需要更新博客的 CSS 文件,因为 Pygments 使用 CSS 选择器来突出显示单词,而不是在页面上浪费地编写输出。它还允许我们根据需要修改颜色。要找出我们的 CSS 应该是什么样子,打开 Python shell 并运行以下命令:
>>> from pygments.formatters import HtmlFormatter
>>> print HtmlFormatter().get_style_defs('.highlight')
前面的命令现在将打印出 Pygments 建议的示例 CSS,我们可以将其复制粘贴到static目录中的.css文件中。
这段代码的其余部分与之前的 Entry 对象没有太大不同。它只是允许您创建、更新和查看代码片段。您会注意到我们在这里使用了一个SnippetForm,我们稍后会定义。
还要创建一个models.py,其中包含以下内容:
class Snippet(db.Model):
STATUS_PUBLIC = 0
STATUS_DRAFT = 1
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
slug = db.Column(db.String(100), unique=True)
body = db.Column(db.Text)
status = db.Column(db.SmallInteger, default=STATUS_PUBLIC)
created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
modified_timestamp = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now)
def __init__(self, *args, **kwargs):
super(Snippet, self).__init__(*args, **kwargs) # Call parent constructor.
self.generate_slug()
def generate_slug(self):
self.slug = ''
if self.title:
self.slug = slugify(self.title)
def __repr__(self):
return '<Snippet: %s>' % self.title
现在我们必须重新运行create_db.py脚本以创建新表。
我们还需要创建一个新的表单,以便可以创建代码片段。在forms.py中添加以下代码:
from models import Snippet
class SnippetForm(wtforms.Form):
title = wtforms.StringField('Title', validators=[DataRequired()])
body = wtforms.TextAreaField('Body', validators=[DataRequired()])
status = wtforms.SelectField(
'Entry status',
choices=(
(Snippet.STATUS_PUBLIC, 'Public'),
(Snippet.STATUS_DRAFT, 'Draft')),
coerce=int)
def save_entry(self, entry):
self.populate_obj(entry)
entry.generate_slug()
return entry
最后,我们需要确保通过编辑main.py文件使用此蓝图并添加以下内容:
from snippets.blueprint import snippets
app.register_blueprint(snippets, url_prefix='/snippets')
一旦我们在这里添加了一些代码,使用Snippet模型,生成的代码将如下图所示呈现:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_09_01.jpg
使用 Markdown 进行简单编辑
Markdown 是一种现在广泛使用的网络标记语言。它允许您以特殊格式编写纯文本,可以通过程序转换为 HTML。在从移动设备编辑文本时,这可能特别有用,例如,突出显示文本使其加粗比在 PC 上更加困难。您可以在daringfireball.net/projects/markdown/上查看如何使用 Markdown 语法。
注意
Markdown 的一个有趣之处在于,您仍然可以同时使用 HTML 和 Markdown。
当然,在 Python 中快速简单地运行这个是很容易的。我们按照以下步骤安装它:
sudo pip install Flask-Markdown
然后我们可以将其应用到我们的蓝图或应用程序中,如下所示:
from flaskext.markdown import Markdown
Markdown(app)
这将在我们的模板中创建一个名为markdown的新过滤器,并且在渲染模板时可以使用它:
{{ entry.body | markdown }}
现在,您只需要在 Markdown 中编写并保存您的博客条目内容。
如前所述,您可能还希望美化代码块;Markdown 内置了这个功能,因此我们需要扩展先前的示例如下:
from flaskext.markdown import Markdown
Markdown(app, extensions=['codehilite'])
现在可以使用 Pygments 来渲染 Markdown 代码块。但是,由于 Pygments 使用 CSS 为代码添加颜色,我们需要从 Pygments 生成我们的 CSS。但是,这次使用的父块具有一个名为codehilite的类(之前称为 highlight),因此我们需要进行调整。在 Python shell 中,键入以下内容:
>>> from pygments.formatters import HtmlFormatter
>>> print HtmlFormatter().get_style_defs('.codehilite')
现在将输出添加到static目录中的.css文件中。因此,使用包含的 CSS,您的 Markdown 条目现在可能如下所示:
https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-flask-fw/img/1709_09_02.jpg
还有许多其他内置的 Markdown 扩展可以使用;您可以查看它们,只需在初始化 Markdown 对象时使用它们的名称作为字符串。
使用 Flask-Cache 和 Redis 进行缓存
有时(我知道很难想象),我们会为我们的网站付出很多努力,添加功能,这通常意味着我们最终不得不为一个简单的静态博客条目执行大量数据库调用或复杂的模板渲染。现在数据库调用不应该很慢,大量模板渲染也不应该引人注目,但是,如果将其扩展到大量用户(希望您是在预期的),这可能会成为一个问题。
因此,如果网站大部分是静态的,为什么不将响应存储在单个高速内存数据存储中呢?无需进行昂贵的数据库调用或复杂的模板渲染;对于相同的输入或路径,获取相同的内容,而且更快。
正如现在已经成为一种口头禅,我们已经可以在 Python 中做到这一点,而且就像以下这样简单:
sudo pip install Flask-Cache
要使其运行,请将其添加到您的应用程序或蓝图中:
from flask.ext.cache import Cache
app = Flask(__name__)
cache = Cache(app, config={'CACHE_TYPE': 'redis'})
当然,您还需要安装 Redis,这在 Debian 和 Ubuntu 系统上非常简单:
sudo apt-get install redis-server
不幸的是,Redis 尚未在 Red Hat 和 CentOS 的打包系统中提供。但是,您可以从他们的网站上下载并编译 Redis
默认情况下,Redis 是不安全的;只要我们不将其暴露给我们的网络,这应该没问题,而且对于 Flask-Cache,我们不需要进行任何其他配置。但是,如果您希望对其进行锁定,请查看 Redis 的 Flask-Cache 配置。
现在我们可以在视图中使用缓存(以及任何方法)。这就像在路由上使用装饰器一样简单。因此,打开一个视图并添加以下内容:
@app.route("/")
@cache.cached(timeout=600) # 10 minutes
def homepage():
…
您将在这里看到,缓存的装饰器在路由内部,并且我们有一个 10 分钟的超时值,以秒为单位。这意味着,无论您的主页的渲染有多繁重,或者它可能进行多少数据库调用,响应都将在该时间段内直接从内存中获取。
显然,缓存有其时间和地点,并且可能是一门艺术。如果每个用户都有一个自定义的主页,那么缓存将是无用的。但是,我们可以缓存模板的部分内容,因此诸如<head>中的所有<link>元素这样的常见区域很少会更改,但是url_for('static', ...)过滤器不必每次重新生成。例如,看下面的代码:
{% cache 1800 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/blog.min.css') }}">
{% endcache %}
前面的代码部分表示链接元素应该缓存 30 分钟,以秒为单位。您可能还希望对脚本的引用进行相同的操作。我们也可以用它来加载最新博客文章的列表,例如。
通过创建安全、稳定的站点版本来创建静态内容
对于低动态内容的高流量网站的一种技术是创建一个简单的静态副本。这对博客非常有效,因为内容通常是静态的,并且每天最多更新几次。但是,您仍然需要为实际上没有变化的内容执行大量数据库调用和模板渲染。
当然,有一个 Flask 扩展程序可以解决这个问题:Frozen-Flask。Frozen-Flask 识别 Flask 应用程序中的 URL,并生成应该在那里的内容。
因此,对于生成的页面,它会生成 HTML,对于 JavaScript 和图像等静态内容,它会将它们提取到一个基本目录中,这是您网站的静态副本,并且可以由您的 Web 服务器作为静态内容提供。
这样做的另一个好处是,网站的活动版本更加安全,因为无法使用 Flask 应用程序或 Web 服务器更改它。
当然,这也有一些缺点。如果您的网站上有动态内容,例如评论,就不再可能以常规方式存储和呈现它们。此外,如果您的网站上有多个作者,您需要一种共享数据库内容的方式,以便它们不会生成网站的单独副本。解决方案将在本节末尾提出。但首先,让我们按照以下方式安装 Frozen-Flask:
pip install Frozen-Flask
接下来,我们需要创建一个名为freeze.py的文件。这是一个简单的脚本,可以自动设置 Frozen-Flask:
from flask_frozen import Freezer
from main import app
freezer = Freezer(app)
if __name__ == '__main__':
freezer.freeze()
以上代码使用了 Frozen-Flask 的所有默认设置,并在以下方式运行:
python freeze.py
将创建(或覆盖)包含博客静态副本的build目录。
Frozen-Flask 非常智能,将自动查找所有链接,只要它们是从根主页按层次引用的;对于博客文章,这样做效果很好。但是,如果条目从主页中删除,并且它们通过另一个 URL 上的存档页面访问,您可能需要向 Frozen-Flask 提供指针以找到它们的位置。例如,将以下内容添加到freeze.py 文件中:
import models
@freezer.register_generator
def archive():
for post in models.Entry.all():
yield {'detail': product.id}
Frozen-Flask 很聪明,并使用 Flask 提供的url_for方法来创建静态文件。这意味着url_for 方法可用的任何内容都可以被 Frozen-Flask 使用,如果无法通过正常路由找到。
在静态站点上发表评论
因此,您可能已经猜到,通过创建静态站点,您会失去一些博客基本原理——这是鼓励交流和辩论的一个领域。幸运的是,有一个简单的解决方案。
博客评论托管服务,如 Disqus 和 Discourse,工作方式类似于论坛,唯一的区别是每个博客帖子都创建了一个主题。您可以免费使用它们的服务来进行讨论,或者使用 Discourse 在自己的平台上免费运行他们的服务器,因为它是完全开源的。
同步多个编辑器
Frozen-Flask 的另一个问题是,对于分布在网络上的多个作者,您如何管理存储帖子的数据库?每个人都需要相同的最新数据库副本;否则,当您生成站点的静态副本时,它将无法创建所有内容。
如果您都在同一个环境中工作,一个解决方案是在网络内的服务器上运行博客的工作副本,并且在发布时,它将使用集中式数据库来创建博客的已发布版本。
然而,如果您都在不同的地方工作,集中式数据库不是理想的解决方案或无法保护,另一个解决方案是使用基于文件系统的数据库引擎,如 SQLite。然后,当对数据库进行更新时,可以通过电子邮件、Dropbox、Skype 等方式将该文件传播给其他人。然后,他们可以从本地运行 Frozen-Flask 创建可发布内容的最新副本。
使用 Celery 进行异步任务
Celery 是一个允许您在 Python 中运行异步任务的库。这在 Python 中特别有帮助,因为 Python 是单线程运行的,您可能会发现自己有一个长时间运行的任务,您希望要么启动并丢弃;要么您可能希望向您网站的用户提供有关所述任务进度的反馈。
一个这样的例子是电子邮件。用户可能会请求发送电子邮件,例如重置密码请求,您不希望他们在生成和发送电子邮件时等待页面加载。我们可以将其设置为启动和丢弃操作,并让用户知道该请求正在处理中。
Celery 能够摆脱 Python 的单线程环境的方式是,我们必须单独运行一个 Celery 代理实例;这会创建 Celery 所谓的执行实际工作的工作进程。然后,您的 Flask 应用程序和工作进程通过消息代理进行通信。
显然,我们需要安装 Celery,我相信您现在可以猜到您需要的命令是以下命令:
pip install celery
现在我们需要一个消息代理服务器。有很多选择;查看 Celery 的网站以获取支持的选择,但是,由于我们已经在 Flask-Cache 设置中设置了 Redis,让我们使用它。
现在我们需要告诉 Celery 如何使用 Redis 服务器。打开 Flask 应用程序配置文件并添加以下行:
CELERY_BROKER_URL = 'redis://localhost:6379/0'
此配置告诉您的 Celery 实例在哪里找到它需要与 Celery 代理通信的消息代理。现在我们需要在我们的应用程序中初始化 Celery 实例。在main.py 文件中添加以下内容:
from celery import Celery
celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'])
这将使用来自 Flask 配置文件的配置创建一个Celery实例,因此我们还可以从 Celery 代理访问celery对象并共享相同的设置。
现在我们需要为 Celery 工作进程做一些事情。在这一点上,我们将利用 Flask-Mail 库:
pip install Flask-Mail
我们还需要一些配置才能运行。将以下参数添加到您的 Flask 配置文件中:
MAIL_SERVER = "example.com"
MAIL_PORT = 25
MAIL_USERNAME = "email_username"
MAIL_PASSWORD = "email_password"
此配置告诉 Flask-Mail 您的电子邮件服务器在哪里。很可能默认设置对您来说已经足够好,或者您可能需要更多选项。查看 Flask-Mail 配置以获取更多选项。
现在让我们创建一个名为tasks.py的新文件,并创建一些要运行的任务,如下所示:
from flask_mail import Mail, Message
from main import app, celery
mail = Mail(app)
@celery.task
def send_password_verification(email, verification_code):
msg = Message(
"Your password reset verification code is: {0}".format(verification_code),
sender="from@example.com",
recipients=[email]
)
mail.send(msg)
这是一个非常简单的消息生成;我们只是生成一封电子邮件,内容是新密码是什么,电子邮件来自哪里(我们的邮件服务器),电子邮件发送给谁,以及假设是用户账户的电子邮件地址,然后发送;然后通过已设置的邮件实例发送消息。
现在我们需要让我们的 Flask 应用程序利用新的异步能力。让我们创建一个视图,监听被 POST 到它的电子邮件地址。这可以在与帐户或主应用程序有关的任何蓝图中进行。
import tasks
@app.route("/reset-password", methods=['POST'])
def reset_password():
user_email = request.form.get('email')
user = db.User.query.filter(email=user_email).first()
if user:
new_password = db.User.make_password("imawally")
user.update({"password_hash": new_password})
user.commit()
tasks.send_password_verification.delay(user.email, new_password)
flash("Verification e-mail sent")
else:
flash("User not found.")
redirect(url_for('homepage'))
前面的视图接受来自浏览器的 POST 消息,其中包含声称忘记密码的用户的电子邮件。我们首先通过他们的电子邮件地址查找用户,以查看用户是否确实存在于我们的数据库中。显然,在不存在的帐户上重置密码是没有意义的。当然,如果他们不存在,用户将收到相应的消息。
但是,如果用户帐户确实存在,首先要做的是为他们生成一个新密码。我们在这里使用了一个硬编码的示例密码。然后更新数据库中的密码,以便用户在收到电子邮件时可以使用它进行登录。一切都搞定后,我们就可以在之前创建的任务上运行.delay,并使用我们想要使用的参数。这会指示 Celery 在准备好时运行底层方法。
注意
请注意,这不是进行密码重置的最佳解决方案。这只是为了说明您可能希望以简洁的方式执行此操作。密码重置是一个令人惊讶地复杂的领域,有很多事情可以做来提高此功能的安全性和隐私性,例如检查 CSRF 值,限制调用方法的次数,并使用随机生成的 URL 供用户重置密码,而不是通过电子邮件发送的硬编码解决方案。
最后,当我们运行 Flask 应用程序时,我们需要运行 Celery 代理;否则,几乎不会发生任何事情。不要忘记,这个代理是启动所有异步工作者的进程。我们可以做的最简单的事情就是从 Flask 应用程序目录中运行以下命令:
celeryd -A main worker
这很简单地启动了 Celery 代理,并告诉它查找main应用程序中的 celery 配置,以便它可以找到配置和应该运行的任务。
现在我们可以启动我们的 Flask 应用程序并发送一些电子邮件。
使用 Flask-script 创建命令行指令
使用 Flask 非常有用的一件事是创建一个命令行界面,这样当其他人使用您的软件时,他们可以轻松地使用您提供的方法,比如设置数据库、创建管理用户或更新 CSRF 密钥。
我们已经有一个类似的脚本,并且可以在这种方式中使用的脚本是第二章中的create_db.py脚本,使用 SQLAlchemy 的关系数据库。为此,再次有一个 Flask 扩展。只需运行以下命令:
pip install Flask-Script
现在,Flask-Script 的有趣之处在于,命令的工作方式与 Flask 中的路由和视图非常相似。让我们看一个例子:
from flask.ext.script import Manager
from main import app
manager = Manager(app)
@manager.command
def hello():
print "Hello World"
if __name__ == "__main__":
manager.run()
您可以在这里看到,Flask-Script 将自己称为 Manager,但管理器也将自己挂钩到 Flask 应用程序中。这意味着您可以通过使用app引用来对 Flask 应用程序执行任何操作。
因此,如果我们将create_db.py应用程序转换为 Flask-Script 应用程序,我们应该创建一个文件来完成这项工作。让我们称之为manage.py,并从文件create_db.py中插入:
from main import db
@manager.command
def create_db():
db.create_all()
所有这些只是设置一个装饰器,以便manage.py带有参数create_db将运行create_db.py中的方法。
现在我们可以从以下命令行运行:
python manage.py create_db
参考
总结
在本章中,我们做了各种各样的事情。您已经看到如何创建自己的 Markdown 渲染器,以便编辑更容易,并将命令移动到 Flask 中,使其更易管理。我们创建了 Atom feeds,这样我们的读者可以在发布新内容时找到它,并创建了异步任务,这样我们就不会在等待页面加载时锁定用户的浏览器。
在我们的最后一章中,我们将学习如何将我们的简单应用程序转变为一个完全部署的博客,具有所有讨论的功能,已经得到保护,并且可以使用。
Flask用户认证与扩展实战
1934

被折叠的 条评论
为什么被折叠?



