• 【Python开发】Flask开发实战:个人博客(一)


    本系列(已完结)包含


    本文要学习的示例程序是一个个人博客程序:Bluelog。博客是典型的 CMSContent Management System内容管理系统),通常由两部分组成:一部分是博客前台,用来展示开放给所有用户的博客内容;另一部分是博客后台,这部分内容仅开放给博客管理员,用来对博客资源进行添加、修改和删除等操作。

    🚀 源码地址:https://github.com/greyli/bluelog
    在这里插入图片描述

    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
    • 2
    • 3
    • 4
    • 5
    • 6

    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
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    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')  # 文章和评论是一对多关系
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    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 指定 ‘一’ 的一方
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    博客程序中的评论要支持存储回复。我们想要为评论添加回复,并在获取某个评论时可以通过关系属性获得相对应的回复,这样就可以在模板中显示出评论之间的对应关系。那么回复如何存储在数据库中呢?

    你当然可以再为回复创建一个 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))
    
    • 1
    • 2
    • 3
    • 4

    2.生成虚拟数据(fakes.py)

    from faker import Faker
    fake = Faker()
    
    • 1
    • 2
    def fake_admin():
    
    def fake_categories(count=10):
    
    def fake_posts(count=50):
    
    def fake_links():
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在基模板 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>
    
    • 1
    • 2
    • 3
    • 4

    3.2 渲染导航链接

    导航栏上的按钮应该在对应的页面显示激活状态。举例来说,当用户单击导航栏上的 “关于” 按钮打开关于页面时,“关于” 按钮应该高亮显示。Bootstrap 为导航链接提供了一个 active 类来显示激活状态,我们需要为当前页面对应的按钮添加 active 类。

    这个功能可以通过判断请求的端点来实现,对 request 对象调用 endpoint 属性即可获得当前的请求端点。如果当前的端点与导航链接指向的端点相同,就为它添加 active 类,显示激活样式。

  • <a href="{{ url_for('blog.index') }}">Homea> li>
    • 1
    • 2
    • 3
  • 有些教程中会使用 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">×button>
    		{{ message[1] }}
    	div>
    	{% endfor %}
    	...
    main>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    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')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    登录表单由用户名字段 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()]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    文章创建表单由标题字段 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.')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    分类创建字段仅包含分类名称字段(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()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    在这个表单中,email 字段使用了用于验证电子邮箱地址的 Email 验证器。另外,因为评论者的站点是可以留空的字段,所以我们使用 Optional 验证器来使字段可以为空。site 字段使用 URL 验证器确保输入的数据为有效的 URL

    和匿名用户的表单不同,管理员不需要填写诸如姓名、电子邮箱等字段。我们单独为管理员创建了一个表单类,这个表单类继承自 CommentForm 类。

    class AdminCommentForm(CommentForm):
        author = HiddenField()
        email = HiddenField()
        site = HiddenField()
    
    • 1
    • 2
    • 3
    • 4

    在这个表单中,姓名、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/')
    def show_category(category_id):
    	return render_template('blog/category.html')
    	
    @blog_bp.route('/post/', methods=['GET', 'POST'])
    def show_post(post_id):
    	return render_template('blog/post.html')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    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):
    	...
    
    • 1
    • 2
    • 3
    • 4
    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='

    New comment in post %s, click the link below to check:

    '
    '

    %s

    '
    '

    Do not reply this email.

    '
    % (post.title, post_url, post_url))
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    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='

    New reply for the comment you left in post %s, click the link below to check:

    '
    '

    %s

    '
    '

    Do not reply this email.

    '
    % (comment.post.title, post_url, post_url))
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    需要注意的是,因为我们的程序实例是通过工厂函数构建的,所以实例化 Thread 类时,我们使用代理对象 current_app 作为 args 参数列表中 app 的值。另外,因为在新建的线程时需要真正的程序对象来创建上下文,所以我们不能直接传入 current_app,而是传入对 current_app 调用 _get_current_object() 方法获取到的被代理的程序实例。


    敬请关注后续更新!

  • 相关阅读:
    SpringSecurity认证和授权流程详解
    react基础--JSX、条件渲染、事件处理
    低能量电子束曝光技术
    (附源码)ssm网上零食销售系统 毕业设计 180826
    网络中地址端口连写方法
    VLC 21年,重新审视低延迟直播
    iOS App Store上传项目报错 缺少隐私政策网址(URL)解决方法
    龙蜥开发者说:我眼里的龙蜥社区:一个包容的大家庭 | 第 10 期
    基于猕猴感觉运动皮层Spike信号的运动解码分析不同运动参数对解码的影响
    ES6——ES6语法知识之 let 和 const 的区别以及解构赋值
  • 原文地址:https://blog.csdn.net/be_racle/article/details/127138519