Python个人博客程序开发实例框架设计
皮皮要HAPPY 人气:0本文要学习的示例程序是一个个人博客程序: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()
方法获取到的被代理的程序实例。
加载全部内容