本文要学习的示例程序是一个个人博客程序:Bluelog。博客是典型的 CMS
(Content Management System
,内容管理系统),通常由两部分组成:一部分是博客前台,用来展示开放给所有用户的博客内容;另一部分是博客后台,这部分内容仅开放给博客管理员,用来对博客资源进行添加、修改和删除等操作。
1.数据库(models.py)
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from bluelog.extensions import db
1.1 管理员 Admin
class Admin(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True) # 主键字段
username = db.Column(db.String(20)) # 用户名
password_hash = db.Column(db.String(128)) # 密码散列值
blog_title = db.Column(db.String(60)) # 博客标题
blog_sub_title = db.Column(db.String(100)) # 博客副标题
name = db.Column(db.String(30)) # 用户姓名
about = db.Column(db.Text) # 关于信息
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def validate_password(self, password):
return check_password_hash(self.password_hash, password)
1.2 分类 Category
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True) # 主键字段
name = db.Column(db.String(30), unique=True) # 分类名称
posts = db.relationship('Post', back_populates='category') # 分类和文章之间是一对多关系
def delete(self):
default_category = Category.query.get(1)
posts = self.posts[:]
for post in posts:
post.category = default_category
db.session.delete(self)
db.session.commit()
1.3 文章 Post
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True) # 主键字段
title = db.Column(db.String(60)) # 标题
body = db.Column(db.Text) # 正文
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) # 时间戳
can_comment = db.Column(db.Boolean, default=True) # 是否能被评论
category_id = db.Column(db.Integer, db.ForeignKey('category.id')) # 所属分类,外键字段
category = db.relationship('Category', back_populates='posts') # 分类和文章之间是一对多关系
comments = db.relationship('Comment', back_populates='post', cascade='all, delete-orphan') # 文章和评论是一对多关系
Comment
模型中创建的外键字段 post_id
存储 Post
记录的主键值。我们在这里设置了级联删除,也就是说,当某个文章记录被删除时,该文章所属的所有评论也会一并被删除,所以在删除文章时不用手动删除对应的评论。
1.4 评论 Comment
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True) # 主键字段
author = db.Column(db.String(30)) # 作者
email = db.Column(db.String(254)) # 电子邮件
site = db.Column(db.String(255)) # 站点
body = db.Column(db.Text) # 正文
from_admin = db.Column(db.Boolean, default=False) # 是否是管理员的评论
reviewed = db.Column(db.Boolean, default=False) # 是否通过审核
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) # 时间戳
replied_id = db.Column(db.Integer, db.ForeignKey('comment.id')) # 外键
post_id = db.Column(db.Integer, db.ForeignKey('post.id')) # 外键
post = db.relationship('Post', back_populates='comments') # 文章和评论是一对多关系
replies = db.relationship('Comment', back_populates='replied', cascade='all, delete-orphan') # 设置级联删除
replied = db.relationship('Comment', back_populates='replies', remote_side=[id]) # 自关联多对一需用 remote_side=id 指定 ‘一' 的一方
博客程序中的评论要支持存储回复。我们想要为评论添加回复,并在获取某个评论时可以通过关系属性获得相对应的回复,这样就可以在模板中显示出评论之间的对应关系。那么回复如何存储在数据库中呢?
你当然可以再为回复创建一个 Reply 模型,然后使用一对多关系将评论和回复关联起来。但是我们将介绍一个更简单的解决办法,因为回复本身也是评论,如果可以在评论模型内建立层级关系,那么就可以在一个模型中表示评论和回复。
这种在同一个模型内的一对多关系在 SQLAlchemy 中被称为邻接列表关系(Adjacency List Relationship)。具体来说,我们需要在 Comment 模型中添加一个外键指向它自身。这样我们就得到一种层级关系:每个评论对象都可以包含多个子评论,即回复。
这个关系和我们之前熟悉的一对多关系基本相同。仔细回想一下一对多关系的设置,我们需要在 “多” 这一侧定义外键,这样 SQLAlchemy 就会知道哪边是 “多” 的一侧。这时关系对 “多” 这一侧来说就是多对一关系。但是在邻接列表关系中,关系的两侧都在同一个模型中,这时 SQLAlchemy 就无法分辨关系的两侧。在这个关系函数中,通过将 remote_side
参数设为 id
字段,我们就把 id
字段定义为关系的远程侧(Remote Side),而 replied_id
就相应地变为本地侧(Local Side),这样反向关系就被定义为多对一,即多个回复对应一个父评论。
集合关系属性 replies
中的 cascade
参数设为 all
,因为我们期望的效果是,当父评论被删除时,所有的子评论也随之删除。
1.5 社交链接 Link
程序还包含了一个添加社交链接的功能。
class Link(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(30))
url = db.Column(db.String(255))
2.生成虚拟数据(fakes.py)
from faker import Faker
fake = Faker()
def fake_admin():
def fake_categories(count=10):
def fake_posts(count=50):
def fake_links():
3.模板
3.1 模板上下文
在基模板的导航栏以及博客主页中需要使用博客的标题、副标题等存储在管理员对象上的数据,为了避免在每个视图函数中渲染模板时传入这些数据,我们在模板上下文处理函数中向模板上下文添加了管理员对象变量(admin)。另外,在多个页面中都包含的边栏中包含分类列表,我们也把分类数据传入到模板上下文中。
from bluelog.models import Admin, Category
def create_app(config_name=None):
...
register_template_context(app)
return app
def register_template_context(app):
@app.context_processor
def make_template_context():
admin = Admin.query.first()
categories = Category.query.order_by(Category.name).all()
return dict(admin=admin, categories=categories)
在基模板 base.html
和主页模板 index.html
中,我们可以直接使用传入的 admin
对象获取博客的标题和副标题。
<div class="page-header">
<h1 class="display-3">{{ admin.blog_title|default('Blog Title') }}</h1>
<h4 class="text-muted"> {{ admin.blog_sub_title|default('Blog Subtitle') }}</h4>
</div>
3.2 渲染导航链接
导航栏上的按钮应该在对应的页面显示激活状态。举例来说,当用户单击导航栏上的 “关于” 按钮打开关于页面时,“关于” 按钮应该高亮显示。Bootstrap 为导航链接提供了一个 active
类来显示激活状态,我们需要为当前页面对应的按钮添加 active
类。
这个功能可以通过判断请求的端点来实现,对 request
对象调用 endpoint
属性即可获得当前的请求端点。如果当前的端点与导航链接指向的端点相同,就为它添加 active
类,显示激活样式。
<li {% if request.endpoint == 'blog.index' %}class="active"{% endif %}>
<a href="{{ url_for('blog.index') }}" rel="external nofollow" >Home</a>
</li>
有些教程中会使用 endswith()
方法来比较端点结尾。但是蓝本拥有独立的端点命名空间,即 “<蓝本名>.<端点名>”,不同的端点可能会拥有相同的结尾,比如 blog.index
和 auth.index
,这时使用 endswith()
会导致判断错误,所以最妥善的做法是比较完整的端点值。
不过在 Bluelog
的模板中我们并没有使用这个 nav_link()
宏,因为 Bootstrap-Flask
提供了一个更加完善的 render_nav_item()
宏,它的用法和我们创建的 nav_link()
宏基本相同。这个宏可以在模板中通过 bootstrap/nav.html
路径导入,它支持的常用参数如下表所示。
3.3 Flash消息分类
我们目前的 Flash
消息应用了 Bootstrap 的 alert-info
样式,单一的样式使消息的类别和等级难以区分,更合适的做法是为不同类别的消息应用不同的样式。比如,当用户访问出错时显示一个黄色的警告消息;而普通的提示信息则使用蓝色的默认样式。Bootstrap 为提醒消息(Alert)提供了 8 种基本的样式类,即 alert-primary
、alert-secondary
、alert-success
、alert-danger
、alert-warning
、alert-light
、alert-dark
。
要开启消息分类,我们首先要在消息渲染函数 get_flashed_messages
中将 with_categories
参数设为 True
。这时会把消息迭代为一个类似于(分类,消息)的元组,我们使用消息分类字符来构建样式类。
<main class="container">
{% for message in get_flashed_messages(with_categories=True) %}
<div class="alert alert-{{ message[0] }}" role="alert">
<button type="button" class="close" data-dismiss="alert">×</button>
{{ message[1] }}
</div>
{% endfor %}
...
</main>
4.表单(forms.py)
Bluelog 中主要包含下面这些表单:登录表单、文章表单、分类表单、评论表单、博客设置表单。这里我们仅介绍登录表单、文章表单、分类表单和评论表单,其他的表单在实现上基本相同,不再详细介绍。
删除资源也需要使用表单来实现,这里之所以没有创建表单类,是因为后面我们会介绍在实现删除操作时为表单实现 CSRF
保护的更方便的做法,届时表单可以手动在模板中写出。
4.1 登录表单
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField
from wtforms.validators import DataRequired
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(1, 20)])
password = PasswordField('Password', validators=[DataRequired(), Length(1, 128)])
remember = BooleanField('Remember me')
submit = SubmitField('Log in')
登录表单由用户名字段 username
、密码字段 password
、“记住我” 复选框 remember
和 “提交” 按钮 submit
组成。
4.2 文章表单
from flask_ckeditor import CKEditorField
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField
from wtforms.validators import DataRequired, Length
from bluelog.models import Category
class PostForm(FlaskForm):
title = StringField('Title', validators=[DataRequired(), Length(1, 60)])
category = SelectField('Category', coerce=int, default=1)
body = CKEditorField('Body', validators=[DataRequired()])
submit = SubmitField()
def __init__(self, *args, **kwargs):
super(PostForm, self).__init__(*args, **kwargs)
self.category.choices = [(category.id, category.name)
for category in Category.query.order_by(Category.name).all()]
文章创建表单由标题字段 title
、分类选择字段 category
、正文字段 body
和 “提交” 按钮组成,其中正文字段使用 Flask-CKEditor
提供的 CKEditorField
字段。
下拉列表字段使用 WTForms 提供的 SelectField
类来表示 HTML 中的 标签。下拉列表的选项(即 标签)通过参数 choices
指定。choices
必须是一个包含两元素元组的列表,列表中的元组分别包含选项值和选项标签。我们使用分类的 id
作为选项值,分类的名称作为选项标签,这两个值通过迭代 Category.query.order_by(Category.name).all()
返回的分类记录实现。选择值默认为字符串类型,我们使用 coerce
关键字指定数据类型为整型。default
用来设置默认的选项值,我们将其指定为 1,即默认分类的 id
。
因为 Flask-SQLAlchemy
依赖于程序上下文才能正常工作(内部使用 current_app
获取配置信息),所以这个查询调用要放到构造方法中执行,在构造方法中对 self.category.choices
赋值的效果和在类中实例化 SelectField
类并设置 choices
参数相同。
4.3 分类表单
from wtforms import StringField, SubmitField, ValidationError
from wtforms import DataRequired
from bluelog.models import Category
class CategoryForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(1, 30)])
submit = SubmitField()
def validate_name(self, field):
if Category.query.filter_by(name=field.data).first():
raise ValidationError('Name already in use.')
分类创建字段仅包含分类名称字段(name)和提交字段。分类的名称要求不能重复,为了避免写入重复的分类名称导致数据库出错,我们在 CategoryForm
类中添加了一个 validate_name
方法,作为 name
字段的自定义行内验证器,它将在验证 name
字段时和其他验证函数一起调用。在这个验证方法中,我们使用字段的值 filed.data
作为 name
列的参数值进行查询,如果查询到已经存在同名记录,那么就抛出 ValidationError
异常,传递错误消息作为参数。
4.4 评论表单
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Email, URL, Length, Optional
class CommentForm(FlaskForm):
author = StringField('Name', validators=[DataRequired(), Length(1, 30)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(1, 254)])
site = StringField('Site', validators=[Optional(), URL(), Length(0, 255)])
body = TextAreaField('Comment', validators=[DataRequired()])
submit = SubmitField()
在这个表单中,email
字段使用了用于验证电子邮箱地址的 Email
验证器。另外,因为评论者的站点是可以留空的字段,所以我们使用 Optional
验证器来使字段可以为空。site
字段使用 URL
验证器确保输入的数据为有效的 URL
。
和匿名用户的表单不同,管理员不需要填写诸如姓名、电子邮箱等字段。我们单独为管理员创建了一个表单类,这个表单类继承自 CommentForm
类。
class AdminCommentForm(CommentForm):
author = HiddenField()
email = HiddenField()
site = HiddenField()
在这个表单中,姓名、Email、站点字段使用 HiddenField
类重新定义。这个类型代表隐藏字段,即 HTML 中的 < input type=“hidden” >。
5.视图函数(blueprints:admin、auth、blog)
在上面我们已经创建了所有必须的模型类、模板文件和表单类。经过程序规划和设计后,我们可以创建大部分视图函数。这些视图函数暂时没有实现具体功能,仅渲染对应的模板,或是重定向到其他视图。以 blog
蓝本为例。
from flask import render_template, Blueprint
blog_bp = Blueprint('blog', __name__)
@blog_bp.route('/')
def index():
return render_template('blog/index.html')
@blog_bp.route('/about')
def about():
return render_template('blog/about.html')
@blog_bp.route('/category/<int:category_id>')
def show_category(category_id):
return render_template('blog/category.html')
@blog_bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id):
return render_template('blog/post.html')
和 blog
蓝本类似,我们在 blueprints
子包中创建了 auth.py
、admin.py
脚本,这些脚本中分别创建了 auth
和 admin
蓝本,蓝本实例的名称分别为 auth_bp
和 admin_bp
。
6.电子邮件支持(emails.py)
因为博客要支持评论,所以我们需要在文章有了新评论后发送邮件通知管理员。而且,当管理员回复了读者的评论后,也需要发送邮件提醒读者。
因为邮件的内容很简单,我们将直接在发信函数中写出正文内容,这里只提供了 HTML 正文。我们有两个需要使用电子邮件的场景:
- 当文章有新评论时,发送邮件给管理员;
- 当某个评论被回复时,发送邮件给被回复用户。
为了方便使用,我们在 emails.py
中分别为这两个使用场景创建了特定的发信函数,可以直接在视图函数中调用。这些函数内部则通过调用我们创建的通用发信函数 send_mail()
来发送邮件。
from flask import url_for
def send_mail(subject, to, html):
...
def send_new_comment_email(post):
post_url = url_for('blog.show_post', post_id=post.id, _external=True) + '#comments'
send_mail(subject='New comment', to=current_app.config['BLUELOG_EMAIL'],
html='<p>New comment in post <i>%s</i>, click the link below to check:</p>'
'<p><a href="%s" rel="external nofollow" rel="external nofollow" >%s</a></P>'
'<p><small style="color: #868e96">Do not reply this email.</small></p>'
% (post.title, post_url, post_url))
send_new_comment_email()
函数用来发送新评论提醒邮件。我们通过将 url_for()
函数的 _external
参数设为 True
来构建外部链接。链接尾部的 #comments
是用来跳转到页面评论部分的URL片段(URL fragment
),comments
是评论部分 div
元素的 id
值。这个函数接收表示文章的 post
对象作为参数,从而生成文章正文的标题和链接。
URL 片段又称片段标识符(fragment identifier
),是 URL 中用来标识页面中资源位置的短字符,以 #
开头,对于 HTML 页面来说,一个典型的示例是文章页面的评论区。假设评论区的 div
元素 id
为 comment
,如果我们访问 http://example.com/post/7#comment
,页面加载完成后将会直接跳到评论部分。
def send_new_reply_email(comment):
post_url = url_for('blog.show_post', post_id=comment.post_id, _external=True) + '#comments'
send_mail(subject='New reply', to=comment.email,
html='<p>New reply for the comment you left in post <i>%s</i>, click the link below to check: </p>'
'<p><a href="%s" rel="external nofollow" rel="external nofollow" >%s</a></p>'
'<p><small style="color: #868e96">Do not reply this email.</small></p>'
% (comment.post.title, post_url, post_url))
send_new_reply_email()
函数则用来发送新回复提醒邮件。这个发信函数接收 comment
对象作为参数,用来构建邮件正文,所属文章的主键值通过 comment.post_id
属性获取,标题则通过 comment.post.title
属性获取。
在 Bluelog 源码中,我们没有使用异步的方式发送邮件,如果你希望编写一个异步发送邮件的通用发信函数 send_mail()
,可以使用以下方式。
from threading import Thread
from flask import current_app
from flask_mail import Message
from bluelog.extensions import mail
def _send_async_mail(app, message):
with app.app_context():
mail.send(message)
def send_mail(subject, to, html):
app = current_app._get_current_object()
message = Message(subject, recipients=[to], html=html)
thr = Thread(target=_send_async_mail, args=[app, message])
thr.start()
return thr
需要注意的是,因为我们的程序实例是通过工厂函数构建的,所以实例化 Thread
类时,我们使用代理对象 current_app
作为 args
参数列表中 app
的值。另外,因为在新建的线程时需要真正的程序对象来创建上下文,所以我们不能直接传入 current_app
,而是传入对 current_app
调用 _get_current_object()
方法获取到的被代理的程序实例。
到此这篇关于Python个人博客程序开发实例框架设计的文章就介绍到这了,更多相关Python个人博客内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!