本章将为应用添加个人主页。个人主页用来展示用户的相关信息,其个人信息可由本用户编辑。我将为你展示如何动态地生成每个用户的主页,并提供一个编辑页面给他们来更新个人信息。
个人主页
作为创建个人主页的第一步,先要编写一个与个人主页对应的视图函数。
# app\routes.py
from flask_login import login_required
# ...
@main_routes.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>
是动态的,Flask 将接受该部分 URL 中的任何文本,并以 username
作为参数名传递给视图函数。
例如,如果浏览器请求 URL /user/susan
,则 user(username)
视图函数将被调用,其参数 username
被设置为 'susan'
。
另外这个视图函数只能被已登录的用户访问,所以添加了 @login_required
装饰器。
个人主页的视图函数当然需要查先找用户实例再返回其个人资料,这里我们使用 username
作为唯一标识符来获得相应的用户对象。这里我们使用 first_or_404()
方法,在没有与 URL 中传来的 username
相匹配的用户实例时,它将自动发送 404 error 给客户端。
以这种方式执行查询,省去了检查用户是否处在的逻辑,因为当用户名不存在于数据库中时,函数将不会返回,而是会引发 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('main.index') }}">Home</a>
{% if current_user.is_anonymous %}
<a href="{{ url_for('main.login') }}">Login</a>
{% else %}
<a href="{{ url_for('main.user', username=current_user.username) }}">
Profile
</a>
<a href="{{ url_for('main.logout') }}">Logout</a>
{% endif %}
</div>
注意这里生成个人主页的 url_for()
函数,它接受一个动态参数,所以它可以根据当前登录用户 current_user
对象的 username
动态生成个人主页链接。
现在我们的个人主页页面如下:
静态文件
我们的网站并不仅仅有来自数据库中存储的数据记录,它也包含各种静态文件,可以是图片、视频,也可以是日后要用到的 Javascript、CSS 文件等。在这里我们先使用静态文件的方法管理用户头像的图片。
Flask 会在 static
文件夹里寻找静态文件,现在我们在 app
文件夹内创建名为 static
的文件夹,静态文件相关的内容放置在这里。为了更进一步细化静态文件的分类管理,我们继续创建 upload/avatar
路径。
文件组织结构如下:
microblog/
app/
static/
upload/
avatar/
templates/
routes.py
# ...
microblog.py
# ...
我们先在 app\static\upload\avatar
文件夹内放置喜欢的图片作为头像使用,为了方便查找到对应的头像,我们用用户名来为图片重命名。为了符合审美,最好使用正方形的图片且文件后缀名为 .png
。
那么怎么访问静态文件呢?假设现在有 diego.png
的图片文件,那么访问它的路径就是 /static/upload/avatar/diego.png
。
当然更好的方法是使用 url_for()
方法:
url_for("static", filename="/upload/avatar/diego.png")
用静态文件构建头像
为了践行低耦合原则,我们先在 config.py
中增添一些配置:
# config.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config(object):
# ...
AVATAR_ROOT = os.path.join(basedir, 'app', 'static', 'upload', 'avatar')
AVATAR_URL = '/upload/avatar/'
AVATAR_EXTENSION = 'png'
这两条并非 Flask 官方或者任何第三方库所必须的配置项,只是我为了方便个人添加上去的,可以在其他模块引入后作为常量使用。
AVATAR_ROOT
记录头像文件夹的绝对路径;AVATAR_URL
记录了头像文件夹相对于 static
文件夹的路径;如果以后位置变化了,直接修改配置文件的相应配置即可,不需要在代码里每一处再修改 ;AVATAR_EXTENSION
记录了头像图片文件的后缀名。
现在在数据库模型的 User
类中添加 avatar()
方法用于生产用户头像的静态文件路径:
import os
from flask import url_for
from config import Config
class User(db.Model, UserMixin):
# ...
def avatar(self):
root = Config.AVATAR_ROOT
url = Config.AVATAR_URL
extension = Config.AVATAR_EXTENSION
filename = "{}/{}.{}".format(url, self.username, extension)
avatar_path = os.path.join(root, '{}.{}'.format(self.username, extension))
if os.path.exists(avatar_path):
return url_for("static", filename=filename)
else:
filename = "{}{}.{}".format(url, 'default', extension)
return url_for("static", filename=filenam
现在我们把之前在配置中设置的 AVATAR_ROOT
和 AVATAR_URL
等配置项引入进了作为常量来使用了。本函数我们通过用户名来构建用户对应的头像文件路径,如果对应的文件不存在,则返回一个默认头像。
下一步我们改写个人主页的 HTML 模板文件,来显示用户的头像:
# app\templates\user.html
{% extends "base.html" %}
{% block content %}
<h1>User: {{ user.username }}</h1>
<img src="{{ user.avatar() }}" weight="80" height="80">
<hr>
{% for post in posts %}
<p>
{{ post.author.username }} says: <b>{{ post.body }}</b>
</p>
{% endfor %}
{% endblock %}
Jinja2 模板语法里除了可以调用对象的属性外,还能直接调用对象的方法,这里我们直接使用了 user
对象的 avatar()
方法。
现在我们的个人主页是这个样子:
使用 Gravatar 构建头像
上一节我们尝试用本地的静态文件来实现头像系统,这意味着我们需要把服务器空间里相当一部分空间用于存放图片,在本节,我们使用一个第三方服务 Gravatar 来实现头像模块,上一节的代码我们暂且清空。
Gravatar(https://en.gravatar.com/)是一项全球通用头像服务,它允许我们把头像文件存储到 Gravatar 服务器中并在其他网站或应用里使用,只要提供你与这个头像关联的 email 地址,就能够显示出你的 Gravatar 头像来。
Gravatar 头像的基础用法是 https://www.gravatar.com/avatar/
+ 电子邮箱的 MD5 哈希值。URL 查询字符串 s
定义图片大小,查询字符串 d
定义默认头像。
以我的头像为例:https://www.gravatar.com/avatar/222db16df13a040d59400787573725bb?s=128&d=robohash
更多用法,我们参见:Gravatar 文档。
现在在数据库模型的 User
类中编写 avatar()
方法,用来生成 Gravatar 头像 URL。
# app\models.py
from hashlib import md5
# ...
class User(db.Model, UserMixin):
# ...
def avatar(self, size):
digest = md5(self.email.lower().encode('utf-8')).hexdigest()
return 'https://www.gravatar.com/avatar/{}?s={}&d=robohash' \
.format(digest, size)
我们先把 email 转化为小写字母,再把字符串编码为字节后生成 MD5 哈希值,用拼接字符串的方式生成头像的 URL。这里用 size
参数设置头像大小,如果头像不存在则随机生成一个机器人图片来做头像,这是 URL 查询字符串 &d=robohash
定义的。
下一步把头像图片插入到个人主页的模板中:
# 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 %}
使用 User
类来返回头像 URL 的好处是,如果有一天我不想继续使用 Gravatar 头像了,我可以重写 avatar()
方法来返回其他头像服务网站的 URL。
现在用户个人主页构建完成:
使用 Jinja2 子模板
在个人主页,我们使用头像和文字组合的方式来展示用户动态。如果我想在主页也使用一样的风格来布局,简单直接的做法就是把 user.html
的相关代码复制粘贴到 index.html
,但如果以后需要修改用户动态的布局,就必须同时修改 user.html
和 index.html
两个模板文件。
聪明的做法是把可以重用的用户动态部分作为子模板,然后在 user.html
和 index.html
模板中引用它。
首先,创建这个只有一条用户动态 HTML 元素的子模板。 我将其命名为 app/templates/_post.html
, _
前缀只是一个命名约定,可以帮助我标记哪些模板文件是子模板。
# app/templates/_post.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 %}
在 user.html
模板中使用了 Jinja2
的 include
语句来调用该子模板:
{% 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 %}
应用的主页还没有完善,所以现在我不打算在其中添加这个功能。
更多有趣的个人资料
我们继续丰富用户个人主页的内容,我会新增用户的自我介绍并在个人主页。同时也将跟踪每个用户最后一次访问该网站的时间,并显示在他们的个人主页上。
为了支持所有这些额外的信息,首先需要做的是用两个新的字段扩展数据库中的用户表:
# 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 ...\migrations\versions\cf1611920c49_new_fields_in_user_model.py ... done
migrate
命令的输出表示一切正确运行,因为它显示 User
类中的两个新字段已被检测到。 现在我可以将此更改应用于数据库:
(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 03f1db8e73ea -> cf1611920c49,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>
<hr>
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
{% endblock %}
新增的两个字段使用了 Jinja2 的 if
语句,设置了字段存在才渲染出来,现在字段为空,所以暂时看不见它。
记录用户的最后访问时间
现在我们实现 last_seen
字段。容易想到这样的逻辑:一旦某个用户向服务器发送请求,就将当前时间写入到这个字段。
为每个视图函数都添加 last_seen
字段的逻辑,显然这不是合理的做法。在视图函数处理请求之前执行一段代码逻辑在 Web 应用中十分常见, 我们利用 Flask 一个内置功能钩子函数来实现它。
我们在 app\routes.py
添加钩子函数 before_request
:
# app\routes.py
from datetime import datetime
@main_routes.before_request
def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
使用了 before_request
装饰器之后,每一次请求前 before_request
函数内的代码都会执行。这里会先检查当前是否有登录的用户,如果当前为已登录用户,则更新它的 last_seen
字段为当前 UTC 时间。
现在可以在个人主页看见最后访问时间了,它可能和你所在时区的实际时间有所不同,我们可以很容易地用 Python 做时区转换,让在不同地区的用户以当地时间格式来显示。现在我们暂且不实现这个功能。
个人资料编辑器
这一节的操作和前面很类似,需要给用户一个表单,让他们通过表单更改个人介绍或用户名。并存储在新的 about_me
字段中。 现在先编写一个表单类吧:
# 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')
对于 about_me
字段,使用 TextAreaField
类型,这是一个多行输入文本框,用户可以在其中输入文本。为了验证这个字段的长度,我使用了 Length
验证器,它将确保输入的文本在 0 到 140 个字符之间。
该表单的渲染模板代码如下:
# 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
@main_routes.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('main.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
,将表单中的数据复制到用户对象中,然后将对象写入数据库。
但是当 validate_on_submit()
返回 False
时,可能是由于两个不同的原因。一个是因为浏览器刚刚发送了一个 GET
请求,这时需要用存储在数据库中的数据预填充字段,以确保这些表单字段具有用户的当前数据。
第二是浏览器发送含表单数据的 POST
请求,但该数据中的某些内容无效。这个时候我们只要留在原地不动,让验证器的提示信息出现在网页上即可。
最后将个人资料编辑页面的链接添加到个人主页,以便用户使用:
{% if user == current_user %}
<p>
<a href="{{ url_for('main.edit_profile') }}">Edit your profile</a>
</p>
{% endif %}
请注意这里使用的 if
语句,它确保编辑个人资料的链接只在浏览自己主页时候才出现。
优化应用结构
回顾我们现在编写的网站应用,现在已经实现了比较多的功能,随着开发的深入,其结构也必将变得臃肿,现在有必要对其进行优化。
我们现在可以按照功能把应用拆成两个子模块:一部分称之为 main
,也就是应用的主要功能模块,我会把跟用户动态相关的功能逻辑放在这个模块里,如当前的 index
主页和以后实现的用户发布动态的功能。
另一个部分叫 auth
,我会把跟用户相关的功能,比如用户注册、用户登录、用户个人主页等功能逻辑放到该模块。
两个不同子模块都会放在 app
路径下,包装成 Python 的包,我会分别用不同的蓝图来对两个子模块进行组织。
拆分后文档结构如下:
app/
auth/ # 与用户相关的逻辑
__init__.py
forms.py
routes.py
main/ # 与动态相关的逻辑
__init__.py
routes.py
templates/
auth\ # 与用户相关的模板文件
base.html
# ...
__init__.py
models.py
migrations/
venv/
app.db
config.py
microblog.py
Blueprints
在 Flask 中,blueprint
是代表应用子集的逻辑结构。blueprint
可以包括路由,视图函数,表单,模板和静态文件等元素。如果在单独的 Python 包中编写 blueprint
,那么你将拥有一个封装了应用特定功能的组件。Blueprint
的内容最初处于休眠状态。为了关联这些元素,blueprint
需要在应用中注册。
我们会现在子模块的 __init__.py
中定义该模块的 blueprint
;__init__.py
能让一个文件夹变成一个 Python 的包,从而用 import
语句调用。比如在 app\main\__init__.py
内定义的代码,我们用 from app.main import xxx
就可引入。
现在按照这个原则来拆分改写我们的应用。
编写 app\main\__init__.py
:
# app\main\__init__.py
from flask import Blueprint
main_routes = Blueprint('main', __name__)
这里我们在定义一个 blueprint
名为 main_routes
,main
子模块下的路由都注册在该蓝图下。
编写 app\main\routes.py
:
# app\main\routes.py
from datetime import datetime
from flask import (
render_template,
)
from flask_login import current_user
from app import db
from app.models import User, Post
from app.main import main_routes
@main_routes.before_request
def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
@main_routes.route('/')
@main_routes.route('/index')
def index():
posts = Post.query.all()
return render_template(
'index.html',
title='Home Page',
posts=posts
)
我们只是把跟用户动态相关的视图函数移动到这个文件下,然后把刚才创建的 main_routes
引入,再把视图函数注册到该蓝图之下。
按照同样的逻辑,我们完成 auth
子模块的拆分。
# app\auth\__init__.py
from flask import Blueprint
auth_routes = Blueprint('auth', __name__)
app\auth\routes.py
里的逻辑参照 main
的部分,把视图函数注册到 auth_routes
中,由于代码过于冗长,就不一一列出,参见源码即可。forms
里的表单类由于全部都是与用户相关的,不做拆分直接移动到 app\auth\forms.py
。
接下来我们也按照功能分类的原则,对 templates
中的模板文件进行分类。新建一个 app\templates\auth
文件夹,edit_profile.html
、login.html
、register.html
、user.html
这些和用户功能相关的模板文件放置其中。其它的模板文件仍然保留在 templates
目录。
修改为成后使用 render_template
调用模板文件时候就要加上其相对路径,如:'auth/login.html'
。
下一个任务就是改写 HTML 模板文件的 url_for()
,由于我们把用户相关的视图函数注册到 auth_routes
并命名为 'auth'
;我们需要把原来 url_for('main.login')
改为 url_for('auth.login')
;同样地 logout
、register
等路由也同样处理。
最后一步,在工厂函数引入新的蓝图并注册到 app
中:
# app\__init__.py
from flask import Flask
from config import Config
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
def create_app():
app = Flask(__name__)
# 加载配置
app.config.from_object(Config)
# 初始化各种扩展库
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
# 引入蓝图并注册
from app.main.routes import main_routes
app.register_blueprint(main_routes)
from app.auth.routes import auth_routes
app.register_blueprint(auth_routes)
return app
from app import models
现在应用结构的修改已经完成,接下来我会按照这个框架继续为网站增添新功能,如果按照功能对其拆分,如果有比较独立的新功能需要开发,还将增添新的子模块。
本章源码:https://github.com/SingleDiego/Flask-Tutorial-Source-Code/tree/SingleDiego-patch-06
网友评论