本系列(已完结)包含:
Flask 开发实战:个人博客(二)
在【Python开发】Flask开发实战:个人博客(一) 中,我们已经完成了 数据库设计、数据准备、模板架构、表单设计、视图函数设计、电子邮件支持 等总体设计的内容,本篇博客将介绍博客前台的实现。博客前台需要开放给所有用户,这里包括 显示文章列表、博客信息、文章内容和评论 等功能。
🚀 源码地址:https://github.com/greyli/bluelog
1.分页显示文章列表
为了在主页显示文章列表,我们要先在渲染主页模板的 index
视图中的数据库中获取所有文章记录并传入模板。
@blog_bp.route('/')
def index():
在主页模板(index.html
)中,我们使用 for 语句迭代所有文章记录,依次渲染文章标题、发表时间和正文。
因为我们已经生成了虚拟数据,其中包含 50 篇文章。现在运行程序,首页会显示一个很长的文章列表,根据创建的随机日期排序,最新发表的排在上面。
1.1 获取分页记录
如果所有的文章都在主页显示,无疑将会延长页面加载时间。而且用户需要拖动滚动条来浏览文章,太长的网页会让人感到沮丧,从而降低用户体验度。更好的做法是对文章数据进行分页处理,每一页只显示少量的文章,并在页面底部显示一个分页导航条,用户通过单击分页导航上的页数按钮来访问其他页的文章。Flask-SQLAlchemy
提供了简单的分页功能,使用 paginate()
查询方法可以分页获取文章记录。
@blog_bp.route('/')
def index():
page = request.args.get('page', 1, type=int) # 从查询字符串获取当前页数
per_page = current_app.config['BLUELOG_POST_PER_PAGE'] # 每页数量
pagination = Post.query.order_by(Post.timestamp.desc()).paginate(page, per_page=per_page) # 分页对象
posts = pagination.items # 当前页数的记录列表
return render_template('blog/index.html', pagination=pagination, posts=posts)
为了实现分页,我们把之前的查询执行函数 all()
换成了 paginate()
,它接收的两个最主要的参数分别用来决定把记录分成几页 per_page
,返回哪一页的记录 page
。page
参数代表当前请求的页数,我们从请求的查询字符串(request.args
)中获取,如果没有设置则使用默认值 1,指定 int
类型可以保证在参数类型错误时使用默认值;per_page
参数设置每页返回的记录数量,为了方便统一修改,这个值从配置变量 BLUELOG_POST_PER_PAGE
获取。
调用查询方法 paginate()
会返回一个 Pagination
类实例,它包含分页的信息,我们将其称为分页对象。对这个 pagination
对象调用 items
属性会以列表的形式返回对应页数(默认为第一页)的记录。在访问这个 URL 时,如果在 URL 后附加了查询参数 page
来指定页数,例如 http://localhost:5000/?page=2
,这时发起请求调用 items
变量将会获得第二页的 10 条记录。
1.2 渲染分页导航部件
我们不能让用户通过在 URL 中附加查询字符串来实现分页浏览,而是应该在页面底部提供一个分页导航部件。这个分页导航部件应该包含上一页、下一页以及跳转到每一页的按钮,每个按钮都包含指向主页的 URL,而且 URL 中都添加了对应的查询参数 page
的值。使用 paginate
方法时,它会返回一个 Pagination
类对象,这个类包含很多用于实现分页导航的方法和属性,我们可以用它来获取所有关于分页的信息。
对于博客来说,设置一个简单的包含上一页、下一页按钮的分页部件就足够了。在视图函数中,我们将分页对象 pagination
传入模板,然后在模板中使用它提供的方法和属性来构建分页部件。
Bootstrap-Flask
已经内置了一个包含同样功能,而且提供更多自定义设置的 render_pager()
宏。除此之外,它还提供了一个 render_pagination()
宏,可以用来渲染一个标准的 Bootstrap Pagination
分页导航部件。render_pagination()
宏支持的常用参数如下表所示。
{% from 'bootstrap/pagination.html' import render_pager %}
...
{{ render_pager(pagination) }}
2.显示文章正文
文章页面通过 show_post
视图渲染,路由的 URL 规则中包含一个 post_id
变量,我们将 post_id
作为主键值来查询对应的文章对象,并传入模板 post.html
中渲染。
@blog_bp.route('/post/<int:post_id>')
def show_post(post_id):
post = Post.query.get_or_404(post_id)
return render_template('blog/post.html', post=post)
3.文章固定链接
在 Bluelog 程序中,文章的固定链接使用文章记录的 id
值来构建,比如 http://example.com/post/120
表示 id
为 120
的文章。如果你想要一个可读性更强、对用户和搜索引擎更友好的固定链接,可以考虑把标题转换成英文或拼音,使用处理后的标题(即 slug)构建固定链接,比如 http://example.com/post/hello-world
表示标题为 Hello World 的文章。
在 Bluelog 中,为了方便用户获取固定链接,我们在文章正文下面添加了一个分享按钮,这个分享按钮用来打开包含文章固定链接的模态框(Modal,又被译为模态对话框)。
4.显示分类文章列表
分页处理在数量上让文章更有组织性,但在文章内容上,我们还需要添加分类来进一步组织文章。在渲染分类页面的 show_category
视图中,首先需要获取对应的分类记录,然后获取分类下的所有文章,进行分页处理,最后将分类记录 category
、分页文章记录 posts
和分页对象 pagination
都传入模板。
@blog_bp.route('/category/<int:category_id>')
def show_category(category_id):
category = Category.query.get_or_404(category_id)
page = request.args.get('page', 1, type=int)
per_page = current_app.config['BLUELOG_POST_PER_PAGE']
pagination = Post.query.with_parent(category).order_by(Post.timestamp.desc()).paginate(page, per_page)
posts = pagination.items
return render_template('blog/category.html', category=category, pagination=pagination, posts=posts)
我们需要获取对应分类下的所有文章,如果我们直接调用 category.posts
,会以列表的形式返回该分类下的所有文章对象,但是我们需要对这些文章记录附加其他查询过滤器和方法,所以不能使用这个方法。在上面的查询中,我们仍然从 post
模型出发,使用 with_parent()
查询方法传入分类对象,最终筛选出属于该分类的所有文章记录。因为调用 with_parent()
查询方法会返回查询对象,所以我们可以继续附加其他查询方法来过滤文章记录。
5.显示评论列表
评论列表在显示文章的页面显示,我们首先在显示文章的 show_post
视图中获取对应的文章,然后使用 Comment.query.with_parent(post)
方法获取文章所属的评论,并对其进行排序和分页处理(per_page
的值通过配置变量 BLUELOG_COMMENT_PER_PAGE
获取),获取对应页数的评论记录,最后传入模板中。
@blog_bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id):
post = Post.query.get_or_404(post_id)
page = request.args.get('page', 1, type=int)
per_page = current_app.config['BLUELOG_COMMENT_PER_PAGE']
pagination = Comment.query.with_parent(post).filter_by(reviewed=True).order_by(Comment.timestamp.asc()).paginate(page, per_page)
comments = pagination.items
return render_template('blog/post.html', post=post, pagination=pagination, form=form, comments=comments)
评论列表里仅需要列出通过审核的评论,所以在视图函数里的数据库查询使用filter_by(reviewed=True)
来筛选出通过审核的评论记录。虽然这个筛选也可以通过在模板中迭代评论列表时通过 reviewed
属性实现,但更合理的做法是尽量在视图函数中实现逻辑操作。
评论是个人博客唯一的社交元素,故不仅要实现添加评论功能,还要在评论上添加回复按钮,这样可以使作者和评论者之间的双向交流变成不同用户之间的多维交流。在页面中,评论有多种组织方式,比如将回复通过缩进嵌套到父评论下面的嵌套式、所有评论都对齐列出的平铺式。Bluelog 中将使用平铺式显示评论列表,回复的评论会显示一个回复标记,并在正文添加被回复的评论内容。
我们在文章正文下方渲染评论列表和分页导航部件。
评论的下方使用 Bootstrap-Flask 提供的 render_pagination()
来渲染一个标准分页导航部件。在调用 render_pagination()
宏时,除了传入分页对象 pagination
外,我们还使用关键字 fragment
传入了向分页按钮的链接中添加的 URL 片段(评论区元素的 id
为 comments
),以便单击分页按钮后跳转到页面的评论部分,而不是停在页面顶部。
6.发表评论与回复
因为评论表单要显示在文章页面的评论列表下方,所以评论数据的验证和保存在 show_post
视图中处理。
from bluelog.models import Comment
from bluelog.forms import AdminCommentForm, CommentForm
from bluelog.emails import send_new_comment_email
@blog_bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id):
post = Post.query.get_or_404(post_id)
page = request.args.get('page', 1, type=int)
per_page = current_app.config['BLUELOG_COMMENT_PER_PAGE']
pagination = Comment.query.with_parent(post).filter_by(reviewed=True).order_by(Comment.timestamp.asc()).paginate(page, per_page)
comments = pagination.items
if current_user.is_authenticated: # 如果当前用户已登录,使用管理员表单
form = AdminCommentForm()
form.author.data = current_user.name
form.email.data = current_app.config['BLUELOG_EMAIL']
form.site.data = url_for('.index')
from_admin = True
reviewed = True
else: # 未登录则使用普通表单
form = CommentForm()
from_admin = False
reviewed = False
if form.validate_on_submit():
author = form.author.data
email = form.email.data
site = form.site.data
body = form.body.data
comment = Comment(
author=author, email=email, site=site, body=body,
from_admin=from_admin, post=post, reviewed=reviewed)
db.session.add(comment)
db.session.commit()
if current_user.is_authenticated: # 根据登录状态不同显示不同的提示信息
flash('Comment published.', 'success')
else:
flash('Thanks, your comment will be published after reviewed.', 'info')
send_new_comment_email(post) # 发送邮件给管理员
return redirect(url_for('.show_post', post_id=post_id))
return render_template('blog/post.html', post=post, pagination=pagination, form=form, comments=comments)
current_user.is_authenticated
变量是从 Flask-Login
导入的,这个布尔值代表当前用户的登录状态。
在处理评论时,我们主要需要对管理员和匿名用户做出区分。首先通过 current_user.is_authenticated
属性判断当前用户的认证状态:如果当前用户已经通过认证,那么就实例化管理员表单类 AdminCommentForm
,并把表单类中的姓名(author)、电子邮箱(email)、站点(site)这三个隐藏字段预先赋予正确的值,创建 from_admin
和 reviewed
变量,两者均设为 True
;如果当前用户是匿名用户,则实例化普通的评论表单类 CommentForm
,创建 from_admin
和 reviewed
变量,两者均设为 False
。
在表单提交并通过验证后,我们像往常一样获取数据并保存。实例化 Comment
类时,传入的 from_admin
和 reviewed
参数值使用对应的变量。在保存评论记录后,我们也需要根据当前用户的认证状态闪现不同的消息:如果当前用户是管理员,发送 “提交评论成功”;如果是匿名用户,则发送 “评论已进入审核队列,审核通过后将显示在评论列表中”,另外还要调用 send_new_comment_email()
函数向管理员发送一个提醒邮件,传入文章对象(post)作为参数。
7.支持回复评论
我们已经在数据库中添加了评论与被回复评论的邻接列表关系,那么如何实现回复功能呢?首先,需要知道当用户单击回复按钮时,对应的是哪一条评论。可以通过渲染一个隐藏的表单来存储被回复评论的 id
,然后在用户提交表单时再查找它。更简单的做法是添加一个新的视图,通过路由 URL 规则中的变量来传递这个值,我们在前面编写评论列表模板时加入了回复按钮。
<a class="btn btn-primary btn-sm" href="{{ url_for('.reply_comment',comment_id=comment.id) }}"></a>
@blog_bp.route('/reply/comment/<int:comment_id>')
def reply_comment(comment_id):
comment = Comment.query.get_or_404(comment_id)
if not comment.post.can_comment:
flash('Comment is disabled.', 'warning')
return redirect(url_for('.show_post', post_id=comment.post.id))
return redirect(
url_for('.show_post', post_id=comment.post_id, reply=comment_id, author=comment.author) + '#comment-form')
在这个视图函数的 return
语句中,我们将程序重定向到原来的文章页面。附加的关键字参数除了必须的 post_id
变量外,我们还添加了两个多余的参数:reply
和 author
,对应的值分别是被回复评论的 id
和被回复评论的作者。url_for()
函数后附加的 URL 片段 #comment-form
用来将页面焦点跳到评论表单的位置。
当使用 url_for()
函数构建 URL 时,任何多余的关键字参数(即未在目标端点的 URL)都会被自动转换为查询字符串。当我们单击某个评论右侧的回复按钮时,重定向后的页面 URL 将会是http://localhost:5000/photo/23?id=4&author=peter#comment-form
。
简单来说,reply_comment
视图扮演了中转站的角色。它把通过 URL 变量接收的数据通过查询字符串传递给了需要处理评论的视图。
下一步,我们需要在回复状态添加提示,在评论表单上方显示一个回复提醒条,让用户知道自己现在处于回复状态。我们在模板中评论表单上方通过 request.args
属性获取查询字符串传递的信息以在回复提示条显示被回复的用户名称。
{% if request.args.get('reply') %}
<div class="alert alert-dark">
Reply to <strong>{{ request.args.get('author') }}</strong>:
<a class="float-right" href="{{ url_for('.show_post',post_id=post.id) }}">Cancel</a>
</div>
{% endif %}
在 show_post
视图中,处理评论的代码也要进行相应更新。
from bluelog.emails import send_new_reply_email
@blog_bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id):
...
if form.validate_on_submit():
...
replied_id = request.args.get('reply')
if replied_id: # 如果 URL 中 reply 查询参数存在,那么说明是回复
replied_comment= Comment.query.get_or_404(replied_id)
comment.replied = replied_comment
send_new_reply_email(replied_comment) # 发送邮件给被回复用户
...
新添加的 if
语句判断请求 URL 的查询字符串中是否包含 replied_id
的值,如果包含,则表示提交的评论是一条回复。我们根据 relied_id
的值查找对应的评论对象,然后存储到被提交评论的 replied
属性以建立数据库关系,最后调用 send_new_reply_email()
函数发送提示邮件给被回复的评论的作者,传入被回复评论作为参数。
8.网站主题切换
主题切换的功能很简单,具体原理就是根据用户的选择加载不同的 CSS 文件。为了方便切换,我们在程序 static 目录下的 CSS 文件夹中下载了两个 Bootswatch 中的 Bootstrap 主题 CSS 文件,分别命名为 perfect_blue.min.css
和 black_swan.min.css
。
在配置文件中,我们新建一个变量,保存主题名称(与 CSS 文件名相对应)和显示名称的映射字典:
# ('theme name', 'display name')
BLUELOG_THEMES = {'perfect_blue': 'Perfect Blue', 'black_swan': 'Black Swan'}
为了让这个功能能够被所有用户使用,我们将会把这个主题选项的值存储在客户端 cookie
中,新创建的 change_theme
视图用于将主题名称保存到名为 theme
的 cookie
中。
@blog_bp.route('/change-theme/<theme_name>')
def change_theme(theme_name):
if theme_name not in current_app.config['BLUELOG_THEMES'].keys():
abort(404)
response = make_response(redirect_back())
response.set_cookie('theme', theme_name, max_age=30 * 24 * 60 * 60)
return response
视图函数中的 if
判断用来确保 URL 变量中的主题名称在支持的范围内,如果出错就返回 404
错误响应。
我们使用 make_response()
方法生成一个重定向响应,这里使用了重定向到上一个页面的重定向辅助函数 redirect_back()
,因为主题切换下拉列表将添加在边栏,用户可能在任一页面切换主题。通过对响应对象 response
调用 set_cookie
设置 cookie
,将主题的名称保存在名为 theme
的 cookie
中,我们使用 max_age
参数将 cookie
的过期时间设为 30 天。
在基模板的 < head> 元素内,我们根据用户的 theme cookie
的值来加载对应的 CSS 文件,如果 theme cookie
的值不存在,则会使用默认值 perfect_blue
,加载默认的 perfect_blue.min.css
。
<link rel="stylesheet"
href="{{ url_for('static', filename='css/%s.min.css' % request.cookies.get('theme', 'perfect_blue')) }}"
type="text/css">
在边栏最下方,我们添加用于设置主题的下拉选择列表。
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="dropdownMenuButton"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Change Theme
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
{% for theme_name, display_name in config.BLUELOG_THEMES.items() %}
<a class="dropdown-item"
href="{{ url_for('blog.change_theme', theme_name=theme_name, next=request.full_path) }}">
{{ display_name }}</a>
{% endfor %}
</div>
</div>
在上面的 HTML 代码中,我们通过迭代主题配置变量 BLUELOG_THEMES
,渲染出下拉选项,选项的 URL 指向 change_theme
端点,传入主题名称作为 URL 变量 theme_name
的值。现在,如果在下拉框中选择 Black Swan,theme cookie
的值就会被设为 black_swan
,页面重载后会加载 black_swan.min.css
,从而起到切换主题的效果。
敬请关注后续更新!