亲宝软件园·资讯

展开

Python个人博客程序开发实例框架设计

皮皮要HAPPY 人气:0

本文要学习的示例程序是一个个人博客程序:Bluelog。博客是典型的 CMSContent 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">&nbsp;{{ 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.indexauth.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-primaryalert-secondaryalert-successalert-dangeralert-warningalert-lightalert-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">&times;</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.pyadmin.py 脚本,这些脚本中分别创建了 authadmin 蓝本,蓝本实例的名称分别为 auth_bpadmin_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 元素 idcomment,如果我们访问 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() 方法获取到的被代理的程序实例。

加载全部内容

相关教程
猜你喜欢
用户评论