7. Flask个人博客实战-文章模块

这一篇介绍flask文章模块的编写,这里会有两个模型,栏目 category,文章 article。 大多数的模型结构都是类似的,都是基于基础的模块,比如商品,在我眼里也就是一个模型而以,他有字段标题,内容,参数等。 现在我们先来创建两个模型,并增加相应的表单,还有蓝图。 这篇文章还要注意下,图片上传,跟编辑器里面的图片上传,我都直接上传到阿里去OSS上。 并不是上传到本地的,这里有个好处就是并发,与带宽

先上一张效果图

也可以参照上一篇 Flask个人博客项目实战-用户模块 的第一步

git clone https://gitee.com/1503319119/flask_blog.git
git reset --hard c0ad20c8ead6ebfbc65b997df7c571862e7ff29f

这里检出到文章的commit来执行查看效果 接下来我们就试着在上一篇文章的基础上来做文章模块

第一步 这里需要两个扩展

pipenv install flask_ckeditor oss2

第二步 定义两个模型

# /app/models/article.py

from app.extensions import db
from flask import request
from datetime import datetime
from app.models.model_mixin import BaseMixin


# article model
class Article(db.Model, BaseMixin):
   id = db.Column(db.Integer, primary_key=True)
   # 栏目ID 外键
   cid = db.Column(db.Integer, db.ForeignKey('category.id'))
   # 缩略图
   thumb = db.Column(db.String(255))
   # 标题
   title = db.Column(db.String(255))
   # 简要说明
   desc = db.Column(db.Text)
   # 正文
   content = db.Column(db.Text)
   # 文章状态 比如正常的文章,已删除的文章
   status = db.Column(db.Integer, default=9, index=True)
   # 排序
   sort = db.Column(db.Integer, default=0, index=True)
   # 是否是外链文章
   url = db.Column(db.String(255))
   # 点击数
   views = db.Column(db.Integer, default=0, index=True)
   # 创建时间
   created_at = db.Column(db.DateTime, default=datetime.now, index=True)
   # 更新时间 onupdate 更新会自动更新此时间
   updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)

   # 关联属性 分类
   category = db.relationship('Category', back_populates='articles')

/app/models/category.py

/app/config.py
from app.extensions import db
from datetime import datetime
from app.models.model_mixin import BaseMixin
from app.models.article import Article


class Category(db.Model, BaseMixin):
   id = db.Column(db.Integer, primary_key=True)
   # 栏目名称
   title = db.Column(db.String(20))
   # 上一级栏目
   pid = db.Column(db.Integer, db.ForeignKey('category.id'), default=None)

   # 关联的子栏目
   children = db.relationship('Category', backref=db.backref('parent', remote_side=[id]))
   # 关联的文章
   articles = db.relationship('Article', back_populates='category')

   # 导出树状数据
   @staticmethod
   def tree():
      tree_dict = []
      data = Category.query.filter_by(pid=None).all()

      def get_tree(tree_root, tree_dict, level=0):
            if not tree_root.children:
               return

            level += 1
            for child in tree_root.children:
               tree_dict.append({
                  'id': child.id,
                  'title': child.title,
                  'level': level
               })
               get_tree(child, tree_dict, level)

      for item in data:
            tree_dict.append({
               'id': item.id,
               'title': item.title,
               'level': 0
            })
            get_tree(item, tree_dict)

      return tree_dict

   def get_count(self):
      return Article.query.filter(Article.cid == self.id, Article.status == 9).count()

这里分开两个文件,就是为了方便管理,你也可以把这category合并到article.py里面去

接着,创建相应的表单

/app/forms/article.py

/app/config.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField, HiddenField, TextAreaField
from flask_ckeditor import CKEditorField
from wtforms.validators import DataRequired, Length, Optional, URL
from app.models.category import Category


class ArticleForm(FlaskForm):
   cid = SelectField('选择栏目',
                     validators=[DataRequired('请选择标签')],
                     coerce=int,
                     choices=[(0, '选择栏目')])
   thumb = HiddenField('缩略图 ', validators=[Optional(), Length(1, 255)])
   title = StringField('标题', validators=[DataRequired('标题不能为空'), Length(1, 255)], render_kw={'placeholder': '输入标题'})
   url = StringField('跳转链接', validators=[Optional(), URL(message='必须是URL')], render_kw={'placeholder': '文章跳转链接,可不填 (http://www.lsol.com.cn)'})
   desc = TextAreaField('简介', validators=[DataRequired('不能为空')])
   content = CKEditorField('文章内容', validators=[DataRequired('不能为空')])
   submit = SubmitField('提交')

   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.cid.choices.extend([(i.id, i.title) for i in Category.query.all()])

   def load(self, data):
      """填充数据
      """
      for k, v in data.__dict__.items():
            if hasattr(self, k):
               self.__dict__[k].data = '' if v is None else v

/app/forms/category.py

/app/config.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField
from wtforms.validators import DataRequired, Length, Optional
from app.models.category import Category


class CategoryForm(FlaskForm):
   pid = SelectField('父栏目',
                     validators=[Optional()],
                     coerce=int,
                     choices=[(0, '选择栏目')])
   title = StringField('栏目名称', validators=[DataRequired(), Length(1, 20)], render_kw={'placeholder': '输入栏目名称'})
   submit = SubmitField('提交')

   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.pid.choices.extend([(i['id'], i['title']) for i in Category.tree()])

   def load(self, data):
      """填充数据
      """
      for k, v in data.__dict__.items():
            if hasattr(self, k):
               self.__dict__[k].data = None if v is None else v

/app/forms/common.py 这里是存放一些不好分类的,但是要用的,比如上传等表单

/app/config.py
from flask_wtf import FlaskForm
from wtforms import StringField
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms.validators import DataRequired, Length


class AjaxDeleteForm(FlaskForm):
   ids = StringField('id', validators=[DataRequired(), Length(1, 300)])


class FileUploadForm(FlaskForm):
   upload = FileField('缩略图', validators=[FileRequired(), FileAllowed(['jpg', 'png', 'jpeg'], '只允许上传图片')])

接着创建蓝图

/app/admin/article.py

/app/config.py
from flask import Blueprint, render_template, \
   current_app, request, flash, redirect, url_for, jsonify
from flask_login import login_required
from app.models.article import Article
from app.models.category import Category
from app.models.user import User
from app.forms.article import ArticleForm
from app.forms.common import AjaxDeleteForm, FileUploadForm
from datetime import datetime
from flask_ckeditor import upload_success, upload_fail
from app.extensions import db
from uuid import uuid4
from flask_login import current_user
from app.helpers import oss_bucket
import json

bp = Blueprint('article', __name__)


@bp.before_request
@login_required
def before_request():
   """before_request的生命周期加入了login_required装饰
   """
   pass


@bp.context_processor
def make_template_context():
   return dict(category=Category.query.all())


@bp.route('/')
def index():
   """文章列表页
   """
   page = request.args.get('page', 1, type=int)
   cid = request.args.get('cid', None, type=int)

   query = Article.query.order_by(
      Article.id.desc()).filter(Article.status == 9)
   if cid:
      query = query.filter(Article.cid == cid)

   pre_page = current_app.config['POST_PRE_PAGE']
   pagination = query.paginate(page, pre_page)
   return render_template('admin/article/index.html',
                           model=pagination.items,
                           page=page,
                           pagination=pagination)


@bp.route('/create', methods=['GET', 'POST'])
def create():
   """文章创建
   """
   form = ArticleForm()
   cid = request.args.get('cid', type=int)
   model = Article()
   if model.save(form, uid=current_user.id):
      flash('添加文章成功', 'success')
      return redirect(url_for('article.index', cid=cid))
   form.cid.data = cid

   return render_template('admin/article/create.html', form=form)


@bp.route('/update/<int:id>', methods=['GET', 'POST'])
def update(id):
   """文章更新
   """
   form = ArticleForm()
   model = Article.query.get_or_404(id)

   if model.save(form):
      flash('更新成功', 'success')
      return redirect(url_for('article.index', cid=model.cid))

   form.load(model)

   return render_template('admin/article/update.html', form=form, model=model)


@bp.route('/view/<int:id>', methods=['GET'])
def view(id):
   """文章查看页
   """
   model = Article.query.get_or_404(id)
   return render_template('admin/article/view.html', model=model)


@bp.route('/delete/<int:id>')
def delete(id):
   """单个删除
   """
   physics = request.args.get('physics', type=int)
   Article.query.get_or_404(id).delete(True if physics == 1 else False)
   flash('删除成功', 'success')
   return redirect(request.referrer or url_for('article.index'))


@bp.route('/ajax_delete', methods=['POST'])
def ajax_delete():
   """批量删除
   """
   form = AjaxDeleteForm()
   physics = request.args.get('physics', type=int)
   if form.validate_on_submit():
      for i in json.loads(form.ids.data):
            Article.query.get_or_404(i).delete(True if physics == 1 else False)
      flash('删除成功', 'success')

   if form.errors:
      flash(form.errors, 'danger')

   return jsonify({'success': True, 'message': '删除成功'})


@bp.route('/trash')
def trash():
   """回收站
   """
   page = request.args.get('page', 1, type=int)
   query = Article.query.filter_by(status=0).order_by(Article.id.desc())
   pre_page = current_app.config['POST_PRE_PAGE']
   pagination = query.paginate(page, pre_page)
   category = Category.query.all()
   return render_template('admin/article/trash.html',
                           model=pagination.items,
                           page=page,
                           pagination=pagination,
                           category=category)


@bp.route('/ajax_restore', methods=['POST'])
def ajax_restore():
   """批量还原
   """
   form = AjaxDeleteForm()
   if form.validate_on_submit():
      for i in json.loads(form.ids.data):
            Article.query.get_or_404(i).status = 9
      flash('还原成功', 'success')
      db.session.commit()

   if form.errors:
      flash(form.errors, 'danger')

   return jsonify({'success': True, 'message': '还原成功'})


@bp.route('/upload', methods=['POST'])
def upload_image():
   """缩略图上传
   """
   fileForm = FileUploadForm()
   if fileForm.validate_on_submit():
      file = request.files.get('upload')
      extension = file.filename.split('.')[1].lower()
      filename = '/'.join([
            datetime.now().strftime('%Y-%m-%d'),
            '{}.{}'.format(uuid4(), extension)
      ])
      oss_bucket.put_object(filename, file.read())
      url = 'https://lsol-house-upload.oss-cn-hangzhou.aliyuncs.com/{}'.format(
            filename)
      return upload_success(url=url)

   return upload_fail(message=fileForm.errors['upload'])

/app/admin/category.py

/app/config.py
from flask import Blueprint, render_template, flash, redirect, url_for
from flask_login import login_required
from app.models.category import Category
from app.forms.category import CategoryForm

bp = Blueprint('category', __name__)


@bp.before_request
@login_required
def before_request():
   pass


@bp.route('/')
def index():
   model = Category.tree()
   return render_template('admin/category/index.html', model=model)


@bp.route('/create', methods=['GET', 'POST'])
def create():
   form = CategoryForm()
   model = Category()
   if model.save(form):
      flash('成功创建栏目{}'.format(model.title), 'success')
      return redirect(url_for('category.index'))
   return render_template('admin/category/create.html', form=form)


@bp.route('/update/<int:id>', methods=['GET', 'POST'])
def update(id):
   form = CategoryForm()
   model = Category.query.get_or_404(id)
   if model.save(form):
      flash('修改成功', 'success')
      return redirect(url_for('category.index'))

   form.load(model)
   return render_template('admin/category/update.html', form=form)


@bp.route('/delete/<int:id>')
def delete(id):
   model = Category.query.get_or_404(id)
   if model.articles:
      flash('栏目下面有文章,请先删除文章后再来删除', 'warning')
      return redirect(url_for('category.index'))
   model.delete(True)
   flash('删除成功', 'success')
   return redirect(url_for('category.index'))

接下来是注册先前pipenv install安装的扩展

/app/extensions.py

/app/config.py
from flask_ckeditor import CKEditor

ckeditor = CKEditor()

/app/__init__.py 引用

/app/config.py
from app.extensions import db, migrate, login_manager, csrf, ckeditor


# 注册扩展
def register_extensions(app):
   ...
   ckeditor.init_app(app)
   ...

另一个oss2我们需要弄一个返回对象

/app/helpers.py

/app/config.py
...
from app.config import Config
import oss2


# 获取oss2
def get_oss_bucket():
   auth = oss2.Auth(Config.OSS_ACCESS_KEY_ID, Config.OSS_ACCESS_KEY_SECRET)
   return oss2.Bucket(auth, Config.OSS_END_POINT, Config.OSS_BUCKET)

oss_bucket = get_oss_bucket()

...

方便其它文件引入 from app.helpers import oss_bucket 接下来。因为引入了两个扩展,我们需要配置一些参数

/app/config.py

/app/config.py
class Config(object):
   ...

   # 这里阿里云参数OSS在.env里面去配置
   OSS_BUCKET_URL = os.environ.get('OSS_BUCKET_URL')
   OSS_ACCESS_KEY_ID = os.environ.get('OSS_ACCESS_KEY_ID')
   OSS_ACCESS_KEY_SECRET = os.environ.get('OSS_ACCESS_KEY_SECRET')
   OSS_END_POINT = os.environ.get('OSS_END_POINT')
   OSS_BUCKET = os.environ.get('OSS_BUCKET')

   # ckeditor编辑器的配置
   CKEDITOR_SERVE_LOCAL = True
   CKEDITOR_ENABLE_CSRF = True
   CKEDITOR_ENABLE_CODESNIPPET = True
   CKEDITOR_FILE_UPLOADER = 'article.upload_image'
   CKEDITOR_HEIGHT = 400

这里主要配置了阿里云的 OSS 参数与 ckeditor 的参数

因为阿里云的参数跟数据库信息一样的类型,都是安全高级别的,所以,我们都存放到.env上。参数可以参考官方文档

/app/config.py
...
OSS_BUCKET_URL = '<OSS_BUCKET_URL>'
OSS_ACCESS_KEY_ID = '<OSS_ACCESS_KEY_ID>'
OSS_ACCESS_KEY_SECRET = '<OSS_ACCESS_KEY_SECRET>'
OSS_END_POINT = '<OSS_END_POINT>'
OSS_BUCKET = '<OSS_BUCKET>'

接着创建 article , category 的两个模块相关文件

article 查看相应的文件 点击此处

这里 /app/templates/admin/article/left.html 文件里面有几个URL没有改动。所以参照如下代码:

/app/config.py
<aside class="left-sidebar">
   <!-- Sidebar scroll-->
   <div class="scroll-sidebar">
      <!-- User profile -->
      <div class="user-profile" style="background: url({{ url_for('static', filename='assets/images/background/user-info.jpg') }}) no-repeat;">
            <!-- User profile image -->
            <div class="profile-img"> <img src="{{ url_for('static', filename='assets/images/users/1.jpg') }}" alt="user" /> </div>
            <!-- User profile text-->
            <div class="profile-text">
               <a href="#" class="dropdown-toggle link u-dropdown" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="true">
                  用户名:角色
                  <span class="caret"></span>
               </a>
               <div class="dropdown-menu animated flipInY">
                  <a href="#" class="dropdown-item"><i class="ti-settings"></i> 账号设置</a>
                  <div class="dropdown-divider"></div>
                  <a href="{{ url_for('auth.logout') }}" class="dropdown-item"><i class="fa fa-power-off"></i> 退出</a>
               </div>
            </div>
      </div>
      <!-- End User profile text-->
      <!-- Sidebar navigation-->
      <nav class="sidebar-nav">
            <ul id="sidebarnav">
               <li class="nav-small-cap">文章</li>
               <li>
                  <a href="{{ url_for('article.index') }}" aria-expanded="false">
                        <i class="fa fa-file-text"></i>
                        <span class="hide-menu">文章管理</span>
                  </a>
               </li>
               <li>
                  <a href="{{ url_for('category.index') }}" aria-expanded="false">
                        <i class="fa fa-file-text"></i>
                        <span class="hide-menu">分类管理</span>
                  </a>
               </li>
               <li>
                  <a href="{{ url_for('article.trash') }}" aria-expanded="false">
                        <i class="fa fa-trash-o"></i>
                        <span class="hide-menu">回收站</span>
                  </a>
               </li>
               <li class="nav-devider"></li>
               <li class="nav-small-cap">系统设置</li>
               <li>
                  <a href="{{ url_for('users.index') }}" aria-expanded="false">
                        <i class="mdi mdi-account"></i>
                        <span class="hide-menu">用户管理</span>
                  </a>
               </li>
            </ul>

      </nav>
      <!-- End Sidebar navigation -->
   </div>
   <!-- End Sidebar scroll-->
   <!-- Bottom points-->
   <div class="sidebar-footer">
      <!-- item-->
      <a href="" class="link" data-toggle="tooltip" title="设置"><i class="ti-settings"></i></a>
      <!-- item-->
      <a href="" class="link" data-toggle="tooltip" title="邮件"><i class="mdi mdi-gmail"></i></a>
      <!-- item-->
      <a href="" class="link" data-toggle="tooltip" title="退出">
            <i class="mdi mdi-power"></i>
      </a>
   </div>
   <!-- End Bottom points-->
</aside>

category 查看相应的文件 点击此处 模板的html篇幅太长了。就不在叙述了。到时看下有需求,我再拎出来讲吧。 现在就可以把蓝图引入到应用了

/app/__init__.py

/app/config.py
from app.admin import site, users, auth, article, category

# 注册蓝本
def register_buleprints(app):
   # frontend
   app.register_blueprint(site.bp)
   app.register_blueprint(users.bp, url_prefix='/users')
   app.register_blueprint(auth.bp, url_prefix='/auth')
   app.register_blueprint(article.bp, url_prefix='/articles')
   app.register_blueprint(category.bp, url_prefix='/category')

引入 article, category 并注册到 app 的路由上

接下来我们把数据库迁移

pipenv run flask db migrate

更新数据表信息

pipenv run flask db upgrade

对数据进行升级

pipenv run flask run

启动服务运行

现在可以愉快的看到 http://127.0.0.1:5000 的页面了,记得哦,出现错误提示不可怕,有提示说明就是好事。