1. 做好准备工作
- 进入项目主目录
- 激活虚拟环境
2. 用户资料页面
首先创建一个映射到 /user/<username> URL的视图函数:
app/routes.py
@app.route('/user/<username>')
@login_required
def user(username):
user = User.query.filter_by(username=username).first_or_404()
posts = [
{'author': user, 'body': 'Test post #1'},
{'author': user, 'body': 'Test post #2'}
]
return render_template('user.html', user=user, posts=posts)
@app.route 装饰器的用法和前文的一样,但是本例中的路由包含了动态部分,也就是用尖括号<>包围的<username> URL。当路由中包含动态部分时,Flask会接受该部分URL中的文本作为参数调用视图函数。 比如说,如果浏览器请求 /user/susan 这个URL,视图函数会将参数 username 设置为 'susan'。 这个视图函数只接受已登录用户的访问,所以我用 @login_required 装饰器装饰了这个视图函数。
这个视图函数的实现相当简单。首先我尝试通过从数据库查询用户名来加载用户。之前说过,要获取全部查询结果,就用all(),如果只想要第一个结果,就用first(),如果没有查询到,就返回None。在这个视图函数中我使用的是first()的变体——first_or_404(),当能查询到结果时它的工作原理和 first() 差不多,但当没有查询到结果时,它会自动将404错误发回到客户端。用这种方式执行查询可以免去检查查询会不会返回用户,因为如果数据库里没有某个用户名,函数会引发404异常而不会返回结果。
如果数据库查询没有触发404错误,那就代表给定用户名的用户已经找到了。
user.html 模板如下所示:
app/templates/user.html
{% extends "base.html" %}
{% block content %}
<h1>User: {{ user.username }}</h1>
<hr>
{% for post in posts %}
<p>
{{ post.author.username }} says: <b>{{ post.body }}</b>
</p>
{% endfor %}
{% endblock %}
现在在导航栏加入资料链接:
app/templates/base.html
<div>
Microblog:
<a href="{{ url_for('index') }}">Home</a>
{% if current_user.is_anonymous %}
<a href="{{ url_for('login') }}">Login</a>
{% else %}
<a href="{{ url_for('user', username=current_user.username) }}">Profile</a>
<a href="{{ url_for('logout') }}">Logout</a>
{% endif %}
</div>
由于用户资料视图函数接受动态参数, url_for()函数以关键字参数的形式接收它的值。由于这个链接指向已登录用户自己的资料页面,所以我可以用 current_user来生成URL。
3. Avatars
处理图像时间很麻烦的事,所以头像部分我们使用 Gravatar 服务。
Gravatar用起来很简单。以 https://www.gravatar.com/avatar/<hash> 格式的URL就能获取到给定用户的图片。<hash> 是用户 email 地址的 MD5 哈希值,下面这个例子演示如何获取 email 地址为 john@example.com 的用户的 Gravatar URL。
>>> from hashlib import md5
>>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest()
'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'
图片的默认尺寸是 80x80 像素,但也可以在URL查询字符串加入 s 参数获取其他尺寸的图片。比如,要获取 128x128 像素的图片,URL就是 https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128 。
另外一个可以传递给 Gravatar 的查询字符串参数是 d ,这是为没有注册 gravatar 的用户提供的默认图像。
注意一些web浏览器屏蔽Gravatar图像,比如 Ghostery。
app/models.py
from hashlib import md5
# ...
class User(UserMixin, db.Model):
# ...
def avatar(self, size):
digest = md5(self.email.lower().encode('utf-8')).hexdigest()
return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
digest, size)
User 类新加入的 avatar() 方法返回用户的avatar图像的URL,并缩放到指定的尺寸。为没有注册avatar的用户生成“identicon”图像。然后,因为Python中的 MD5 支持 bytes 而不是字符串, 所以我在邮箱参数传给hash函数前把字符串编码成字节。
下一步是把 avatar 图像插入到用户资料模板中:
app/templates/user.html
{% extends "base.html" %}
{% block content %}
<table>
<tr valign="top">
<td><img src="{{ user.avatar(128) }}"></td>
<td><h1>User: {{ user.username }}</h1></td>
</tr>
</table>
<hr>
{% for post in posts %}
<table>
<tr valign="top">
<td><img src="{{ post.author.avatar(36) }}"></td>
<td>{{ post.author.username }} says:<br>{{ post.body }}</td>
</tr>
</table>
{% endfor %}
{% endblock %}
用用户类来返回 avatar 图像的好处是如果某天我决定修改 Gravatar avatars了,我可以直接重写 avatar() 方法,然后返回别的URL就行。
4. 使用Jinja2子模版
现在我希望用户写的帖子在index页面也显示,布局也差不多,可以直接复制粘贴这些代码,但是以后我想修改的时候就要改两个地方,很麻烦。
所以,可以用子模版来渲染单个帖子,然后在user.html和index.html页面引用这个子模版,现在创建包含单个帖子的HTML文件,命名为 app/templates/_post.html,_ 前缀是个命名约定,有助于识别哪些模板是子模版。
app/templates/_post.html
<table>
<tr valign="top">
<td><img src="{{ post.author.avatar(36) }}"></td>
<td>{{ post.author.username }} says:<br>{{ post.body }}</td>
</tr>
</table>
用 Jinja2的 include 来调用子模版:
app/templates/user.html
{% extends "base.html" %}
{% block content %}
<table>
<tr valign="top">
<td><img src="{{ user.avatar(128) }}"></td>
<td><h1>User: {{ user.username }}</h1></td>
</tr>
</table>
<hr>
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
{% endblock %}
5. 更有意思的资料页面
首先,修改用户模型,添加两个新字段。
app/models.py
class User(UserMixin, db.Model):
# ...
about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
生成新的迁移脚本:
(venv) $ flask db migrate -m "new fields in user model"
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'user.about_me'
INFO [alembic.autogenerate.compare] Detected added column 'user.last_seen'
Generating /home/username/projectname/migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done
然后向数据库应用更改:
(venv) $ flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model
向模板添加两个新字段:
app/templates/user.html
{% extends "base.html" %}
{% block content %}
<table>
<tr valign="top">
<td><img src="{{ user.avatar(128) }}"></td>
<td>
<h1>User: {{ user.username }}</h1>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
{% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
</td>
</tr>
</table>
...
{% endblock %}
由于现在这两个字段还没有值,所以现在还看不到。
6. 记录用户最后一次访问的时间
要实现这个功能,要做的就是每当用户向服务器发送请求时,向该用户的此字段写入当前时间。
直接向每个可能被访问的视图函数添加这个功能很麻烦,Flask的特性允许向视图函数添加通用的功能,解决方案如下:
app/routes.py
from datetime import datetime
@app.before_request
def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
被@before_request 装饰器装饰的函数会在视图函数请求之前执行,也就是说我现在可以在任何视图函数被请求之前插入代码了。实现很简单,只需要检查 current_user 有没有登录,然后将 last_seen 字段设置为当前时间。就像之前提到的,要想应用程序的时间一致,那就要使用UTC时区,所以用用户系统的本地时间不是个好主意,因为数据库中存的数据会取决于你的位置。最后一步是提交数据库事务,这样上面的这些更改才能写入数据库,考虑到在引用 current_user 时, Flask-Login会调用用户加载回调函数,运行数据库查询并将目标用户添加到数据库会话中,所以没有必要再重复在函数里加上 db.session.add() 了。
7. 资料编辑器
现在开始编写资料编辑表单类:
app/forms.py
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
# ...
class EditProfileForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
submit = SubmitField('Submit')
资料编辑器的模板如下:
app/templates/edit_profile.html
{% extends "base.html" %}
{% block content %}
<h1>Edit Profile</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.about_me.label }}<br>
{{ form.about_me(cols=50, rows=4) }}<br>
{% for error in form.about_me.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}
最后是视图函数:
app/routes.py
from app.forms import EditProfileForm
@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm()
if form.validate_on_submit():
current_user.username = form.username.data
current_user.about_me = form.about_me.data
db.session.commit()
flash('Your changes have been saved.')
return redirect(url_for('edit_profile'))
elif request.method == 'GET':
form.username.data = current_user.username
form.about_me.data = current_user.about_me
return render_template('edit_profile.html', title='Edit Profile',
form=form)
如果validate_on_submit() 返回 True,我就将表单中的数据写入用户的数据库。如果返回False,原因可能有两个,第一个,可能是因为浏览器发送的是GET请求,应用会响应原先的表单数据,也可能是因为浏览器发送的是 POST 请求,但是某些数据没有通过验证。当表单第一次以GET来请求表单的时候,我会用数据库中原先储存着的数据来预先填充这些字段。但是如果验证错误了,我不希望这些向表单字段写入任何数据。可以提供检查 request.method 来区分这两种情况, GET 代表第一次请求, POST 代表提交数据。
现在在用户页面添加修改资料的链接:
app/templates/user.html
{% if user == current_user %}
<p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
{% endif %}