2024年5月4日 星期六

Flask網頁開發 - 第二版 - 部落格文章、追隨者與評論 (5)

  來說明這本由Miguel Grinberg著作,賴屹民翻譯的《Flask網頁開發 第二版》(Flask Web Development, 2nd Edition),本書的撰寫方式如同網頁開發人員新增應用程式功能,每新增一項功能,就會有新的章節說明這樣的新功能,是使用什麼樣的技術製作而成;由於網頁應用程式具結構性,程式碼之間的撰寫與修改是息息相關的,故作者有提供Repository供讀者檢視與試驗。

  我們可以依照本書各章節的順序,逐一檢視各章節在Repository對應的 Branch,藉由各個Branch的切換與推移,可以學習、理解與推演作者修改程式的邏輯,這對於Flask的學習是非常重要的。

  「Flask網頁開發」系列網誌,整理的方向也只是羅列修改方向一致的各個Branch,統整為較容易查找的功能主題,並輔以註解說明特殊模組、套件的API如何使用,以及使用的目的,對於完全沒有網頁應用程式開發經驗的讀者來說,依舊是極度難以理解的一系列筆記,建議還是得先學習好Flask的基礎,再掌握好本書作者訂定的目錄框架,最後才能掌握住本書的程式開發方式。

第二部分、範例:社群部落格app

第十一章、部落格文章-部落格文章和分頁(11a11b11c11d)

l   部落格文章和分頁:
git checkout 11a
git checkout 11b
git checkout 11c
git checkout 11d

l   將長串的部落格文章清單分頁,先建立偽造的部落格文章資料,使用的套件為faker
pip install faker

l   app/fake.py
#
產生偽造的使用者與部落格文章
from random import randint
from sqlalchemy.exc import IntegrityError
from faker import Faker
from . import db
from .models import User, Post

def users(count=100):
    fake = Faker()
    i = 0
    while i < count:
        u = User(email=fake.email(), username=fake.user_name(), password="password", confirmed=True, name=fake.name(), location=fake.city(), about_me=fake.text(), member_since=fake.past_date())
        db.session.add(u)
        try:
            db.session.commit()
            i += 1
        #
如果它不幸產生重複的資料,當你送出資料庫時,會得到IntegrityError異常,此時會回復session,取消重複的使用者
        except IntegrityError:
            db.session.rollback()

def posts(count=100):
    fake = Faker()
    user_count = User.query.count()
    for i in range(count):
        # offset()
這個過濾器會丟棄以引數傳入的結果數量,藉由設定隨機的偏移值再呼叫first()
        u = User.query.offset(randint(0, user_count - 1)).first()
        p = Post(body=fake.text(), timestamp=fake.past_date(), author=u)
        db.session.add(p)
    db.session.commit()

l   Flask Shell產生偽造的使用者與部落格文章:
flask shell
from app import fake
fake.users(100)
fake.posts(100)

l   config.py
# ...
class Config:
    FLASKY_POSTS_PER_PAGE = 20
# ...

l   flasky.py
# ...
from app.models import User, Role, Permission, Post
# ...
@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role, Permission=Permission, Post=Post)
# ...

l   app/models.py
# ...
class User(UserMixin, db.Model):
    # ...
    posts = db.relationship("Post", backref="author", lazy="dynamic")
# ...
class Post(db.Model):
    __tablename__ = "posts"
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    author_id = db.Column(db.Integer, db.ForeignKey("users.id"))

l   app/main/forms.py
# ...
class PostForm(FlaskForm):
    body = TextAreaField("What's on your mind?", validators=[DataRequired()])
    submit = SubmitField("Submit")

l   app/main/views.py
# ...
@main.route("/", methods=["GET", "POST"])
def index():
    form = PostForm()
    if current_user.can(Permission.WRITE) and form.validate_on_submit():
        post = Post(body=form.body.data, author=current_user._get_current_object())
        db.session.add(post)
        db.session.commit()
        return redirect(url_for(".index"))
    #
如果沒有取得頁數,我們就使用預設的頁1
    page = request.args.get("page", 1, type=int)
    # Flask-SQLAlchemy
paginate()方法的第一個且唯一必須的引數是頁數
    # error_out
設為False會用一個空的項目串列來回傳超出有效範圍的網頁,而非404錯誤
    pagination = Post.query.order_by(Post.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    return render_template("index.html", form=form, posts=posts, pagination=pagination)

@main.route("/user/<username>")
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get("page", 1, type=int)
    pagination = user.posts.order_by(Post.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    return render_template("user.html", user=user, posts=posts, pagination=pagination)
# ...

l   app/templates/index.html
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}

{% block title %}Flasky{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1>
</div>
<div>
    {% if current_user.can(Permission.WRITE) %}
    {{ wtf.quick_form(form) }}
    {% endif %}
</div>
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}
{% endblock %}

l   app/templates/_macros.html
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
    <li{% if not pagination.has_prev %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}">
            <!-- &laquo;
是跳脫字元<<的意思 -->
            &laquo;
        </a>
    </li>
    {% for p in pagination.iter_pages() %}
        {% if p %}
            <!--
目前的頁數 -->
            {% if p == pagination.page %}
            <li class="active">
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% else %}
            <li>
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% endif %}
        {% else %}
        <!-- &hellip;
是跳脫字元…的意思 -->
        <li class="disabled"><a href="#">&hellip;</a></li>
        {% endif %}
    {% endfor %}
    <li{% if not pagination.has_next %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}">
            <!-- &raquo;
是跳脫字元>>的意思 -->
            &raquo;
        </a>
    </li>
</ul>
{% endmacro %}

l   app/templates/_posts.html
<ul class="posts">
    {% for post in posts %}
    <li class="post">
        <!--
頭像部分 -->
        <div class="post-thumbnail">
            <a href="{{ url_for('.user', username=post.author.username) }}">
                <img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
            </a>
        </div>
        <!--
文字部分 -->
        <div class="post-content">
            <div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
            <div class="post-author"><a href="{{ url_for('.user', username=post.author.username) }}">{{ post.author.username }}</a></div>
            <div class="post-body">{{ post.body }}</div>
        </div>
    </li>
    {% endfor %}
</ul>

l   app/templates/user.html
<!-- ... -->
{% block page_content %}
<!-- ... -->
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.user', username=user.username) }}
</div>
{% endif %}
{% endblock %}

l   Flask-SQLALchemy分頁物件屬性:

屬性

說明

items

在目前分頁內的項目

query

被分頁的查詢指令

page       

目前的頁數

prev_num

上一頁的頁數

next_num

下一頁的頁數

has_next

如果有下一頁則為True

has_prev

如果有上一頁則為True

pages

查詢得到的總頁數

per_page

每頁的項目數量

total

查詢指令回傳的總項目數量

l   Flask-SQLALchemy分頁物件方法:

方法

說明

iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2)

例如在總共100頁的第50頁,它會回傳這些頁面:12None4849505152535455None99100

prev()

上一頁的分頁物件

next()

下一頁的分頁物件

第十一章、部落格文章-豐富文字格式(11e11f)

l   豐富文字格式:
git checkout 11e
git checkout 11f

l   使用MarkdownFlask-PageDown來製作豐富文字文章:
PageDown
是用JavaScript寫成的用戶端MarkdownHTML轉換器。
Flask-PageDown
是供Flask使用的PageDown包裝器,可整合PageDownFlask-WTF表單。
Markdown
是以Python實作的伺服器端MarkdownHTML轉換器。
Bleach
是以Python實作的HTML消毒器(Sanitizer)
pip install flask-pagedown markdown bleach

l   app/__init__.py
from flask_pagedown import PageDown
# ...
pagedown = PageDown()
# ...
def create_app(config_name):
    # ...
    pagedown.init_app(app)
    # ...

l   app/models.py
# ...
from markdown import markdown
import bleach
# ...

class Post(db.Model):
    # ...
    body_html = db.Column(db.Text)
    # ...
   
    @staticmethod
    def on_changed_body(target, value, oldvalue, initiator):
        # markdown()
函式會初步將內文轉換成HTMLclean()函式會將不在白名單上的所有標籤移除,linkify()函式可將以一般文字寫成的URL轉換成適當的<a>連結
        allowed_tags = ["a", "abbr", "acronym", "b", "blockquote", "code", "em", "i", "li", "ol", "pre", "strong", "ul", "h1", "h2", "h3", "p"]
        target.body_html = bleach.linkify(bleach.clean(markdown(value, output_format="html"), tags=allowed_tags, strip=True))

#
on_changed_body()函式設成bodySQLAlchemy "set"事件的監聽器,每當body欄位被設為新值時,它就會被自動呼叫
db.event.listen(Post.body, "set", Post.on_changed_body)

l   app/main/forms.py
# ...
from flask_pagedown.fields import PageDownField
# ...

class PostForm(FlaskForm):
    #
從原本的TextAreaField改為PageDownField
    body = PageDownField("What's on your mind?", validators=[DataRequired()])
    submit = SubmitField("Submit")

l   app/templates/index.html
<!-- ... -->
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

l   app/templates/_posts.html
<!-- ... -->
<div class="post-body">
    {% if post.body_html %}
        <!-- safe
是為了告訴Jinja2不要轉譯escape HTML元素,因為Markdown生成的HTML是伺服器產生的,所以已直接轉譯 -->
        {{ post.body_html | safe }}
    {% else %}
        {{ post.body }}
    {% endif %}
</div>
<!-- ... -->

第十一章、部落格文章-部落格文章的永久連結(11g)

l   部落格文章的永久連結:
git checkout 11g

l   app/main/views.py
# ...
@main.route("/post/<int:id>")
def post(id):
    post = Post.query.get_or_404(id)
    return render_template("post.html", posts=[post])

l   app/templates/post.html
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - Post{% endblock %}

{% block page_content %}
{% include '_posts.html' %}
{% endblock %}

l   app/templates/_posts.html
<!-- ... -->
<div class="post-footer">
    <a href="{{ url_for('.post', id=post.id) }}">
        <span class="label label-default">Permalink</span>
    </a>
</div>
<!-- ... -->

第十一章、部落格文章-部落格文章編輯器(11h)

l   部落格文章編輯器:
git checkout 11h

l   app/main/views.py
# ...
@main.route("/edit/<int:id>", methods=["GET", "POST"])
@login_required
def edit(id):
    post = Post.query.get_or_404(id)
    if current_user != post.author and not current_user.can(Permission.ADMIN):
        abort(403)
    form = PostForm()
    if form.validate_on_submit():
        post.body = form.body.data
        db.session.add(post)
        db.session.commit()
        flash("The post has been updated.")
        return redirect(url_for(".post", id=post.id))
    form.body.data = post.body
    return render_template("edit_post.html", form=form)

l   app/templates/edit_post.html
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Edit Post{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Edit Post</h1>
</div>
<div>
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

l   app/templates/_posts.html
<!-- ... -->
<div class="post-footer">
    {% if current_user == post.author %}
    <a href="{{ url_for('.edit', id=post.id) }}">
        <span class="label label-primary">Edit</span>
    </a>
    {% elif current_user.is_administrator() %}
    <a href="{{ url_for('.edit', id=post.id) }}">
        <span class="label label-danger">Edit [Admin]</span>
    </a>
    {% endif %}
    <a href="{{ url_for('.post', id=post.id) }}">
        <span class="label label-default">Permalink</span>
    </a>
</div>
<!-- ... -->

第十二章、追隨者-資料庫與應用程式中的追蹤者(12a12b)

l   資料庫與應用程式中的追蹤者:
git checkout 12a
git checkout 12b

l   SQLAlchemy多對多關係範例:
#
完全受SQLAlchemy管理的內部資料表
registrations = db.Table("registrations",
    db.Column("student_id", db.Integer, db.ForeignKey("students.id")),
    db.Column("class_id", db.Integer, db.ForeignKey("classes.id"))
)

class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String)
    classes = db.relationship("Class", secondary= registrations, backref=db.backref("students", lazy="dynamic"), lazy="dynamic")

class Class(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String)

#
假設有位學生s與一門課程c,幫該位學生註冊該堂課
s.classes.append(c)
db.session.add(s)

#
列出學生s註冊的課程以及列出註冊c課程的學生名單
s.classes.all()
c.students.all()

#
學生s決定不修習課程c
s.classes.remove(c)

l   app/models.py
# ...
#
本章追隨者為自我參考(self-referential)關係,以模型來製作追隨關聯表
class Follow(db.Model):
    __tablename__ = "follows"
    #
u1追蹤u2,此記錄u1ID
    follower_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
    #
u1追蹤u2,此記錄u2ID
    followed_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow)

class User(UserMixin, db.Model):
    # ...
    #
用兩個一對多關係來製作多對多關係
    #
必須以foreign_keys引數在各個關係中指定該使用哪個外鍵,以去除外鍵的不明確
    # lazy="joined"
會讓join查詢指令立刻載入相關物件,如果設為預設值select,被第一次讀取時會惰性載入(loaded lazily),而且各個屬性都需要使用個別的查詢指令
    #
使用all, delete-orphan值可啟用預設的cascade選項,並加入刪除孤兒紀錄的行為,因為預設的cascade行為是將它連結的任何物件的外鍵設為null
    #
被自己追蹤的人
    followed = db.relationship("Follow", foreign_keys=[Follow.follower_id], backref=db.backref("follower", lazy="joined"), lazy="dynamic", cascade="all, delete-orphan")
    #
追蹤自己的人
    followers = db.relationship("Follow", foreign_keys=[Follow.followed_id], backref=db.backref("followed", lazy="joined"), lazy="dynamic", cascade="all, delete-orphan")
   
    # ...
    #
追蹤對方
    def follow(self, user):
        if not self.is_following(user):
            f = Follow(followed=user)
            self.followed.append(f)
   
    #
取消追蹤對方
    def unfollow(self, user):
        f = self.followed.filter_by(followed_id=user.id).first()
        if f:
            self.followed.remove(f)
   
    def is_following(self, user):
        if user.id is None:
            return False
        return self.followed.filter_by(followed_id=user.id).first() is not None
   
    def is_followed_by(self, user):
        if user.id is None:
            return False
        return self.followers.filter_by(follower_id=user.id).first() is not None

l   app/main/views.py
# ...
#
追蹤對方
@main.route("/follow/<username>")
@login_required
@permission_required(Permission.FOLLOW)
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash("Invalid user.")
        return redirect(url_for(".index"))
    if current_user.is_following(user):
        flash("You are already following this user.")
        return redirect(url_for(".user", username=username))
    current_user.follow(user)
    db.session.commit()
    flash("You are now following %s." % username)
    return redirect(url_for(".user", username=username))

#
取消追蹤對方
@main.route("/unfollow/<username>")
@login_required
@permission_required(Permission.FOLLOW)
def unfollow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash("Invalid user.")
        return redirect(url_for(".index"))
    if not current_user.is_following(user):
        flash("You are not following this user.")
        return redirect(url_for(".user", username=username))
    current_user.unfollow(user)
    db.session.commit()
    flash("You are not following %s anymore." % username)
    return redirect(url_for(".user", username=username))

#
顯示追蹤自己的人
@main.route("/followers/<username>")
def followers(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash("Invalid user.")
        return redirect(url_for(".index"))
    page = request.args.get("page", 1, type=int)
    pagination = user.followers.paginate(page=page, per_page=current_app.config["FLASKY_FOLLOWERS_PER_PAGE"], error_out=False)
    follows = [{"user": item.follower, "timestamp": item.timestamp} for item in pagination.items]
    return render_template("followers.html", user=user, title="Followers of", endpoint=".followers", pagination=pagination, follows=follows)

#
顯示被自己追蹤的人
@main.route("/followed_by/<username>")
def followed_by(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash("Invalid user.")
        return redirect(url_for(".index"))
    page = request.args.get("page", 1, type=int)
    pagination = user.followed.paginate(page=page, per_page=current_app.config["FLASKY_FOLLOWERS_PER_PAGE"], error_out=False)
    follows = [{"user": item.followed, "timestamp": item.timestamp} for item in pagination.items]
    return render_template("followers.html", user=user, title="Followed by", endpoint=".followed_by", pagination=pagination, follows=follows)

l   app/templates/user.html
<!-- ... -->
<!--
在個人資訊網頁頁首改善追蹤功能 -->
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
    {% if not current_user.is_following(user) %}
    <a href="{{ url_for('.follow', username=user.username) }}" class="btn btn-primary">Follow</a>
    {% else %}
    <a href="{{ url_for('.unfollow', username=user.username) }}" class="btn btn-default">Unfollow</a>
    {% endif %}
{% endif %}
<a href="{{ url_for('.followers', username=user.username) }}">Followers: <span class="badge">{{ user.followers.count() }}</span></a>
<a href="{{ url_for('.followed_by', username=user.username) }}">Following: <span class="badge">{{ user.followed.count() }}</span></a>
{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}
| <span class="label label-default">Follows you</span>
{% endif %}
<!-- ... -->

l   app/templates/followers.html
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>{{ title }} {{ user.username }}</h1>
</div>
<table class="table table-hover followers">
    <thead><tr><th>User</th><th>Since</th></tr></thead>
    {% for follow in follows %}
    <tr>
        <td>
            <a href="{{ url_for('.user', username = follow.user.username) }}">
                <img class="img-rounded" src="{{ follow.user.gravatar(size=32) }}">
                {{ follow.user.username }}
            </a>
        </td>
        <td>{{ moment(follow.timestamp).format('L') }}</td>
    </tr>
    {% endfor %}
</table>
<div class="pagination">
    {{ macros.pagination_widget(pagination, endpoint, username = user.username) }}
</div>
{% endblock %}

l   tests/test_user_model.py
import unittest
import time
from datetime import datetime
from app import create_app, db
from app.models import User, AnonymousUser, Role, Permission, Follow

class UserModelTestCase(unittest.TestCase):
    # ...
   
    def test_follows(self):
        u1 = User(email="john@example.com", password="cat")
        u2 = User(email="susan@example.org", password="dog")
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        self.assertFalse(u1.is_following(u2))
        self.assertFalse(u1.is_followed_by(u2))
        timestamp_before = datetime.utcnow()
        u1.follow(u2)
        db.session.add(u1)
        db.session.commit()
        timestamp_after = datetime.utcnow()
        self.assertTrue(u1.is_following(u2))
        self.assertFalse(u1.is_followed_by(u2))
        self.assertTrue(u2.is_followed_by(u1))
        self.assertTrue(u1.followed.count() == 1)
        self.assertTrue(u2.followers.count() == 1)
        f = u1.followed.all()[-1]
        self.assertTrue(f.followed == u2)
        self.assertTrue(timestamp_before <= f.timestamp <= timestamp_after)
        f = u2.followers.all()[-1]
        self.assertTrue(f.follower == u1)
        u1.unfollow(u2)
        db.session.add(u1)
        db.session.commit()
        self.assertTrue(u1.followed.count() == 0)
        self.assertTrue(u2.followers.count() == 0)
        self.assertTrue(Follow.query.count() == 0)
        u2.follow(u1)
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        db.session.delete(u2)
        db.session.commit()
        self.assertTrue(Follow.query.count() == 0)

第十二章、追隨者-追蹤的部落格文章(12c12d)

l   追蹤的部落格文章:
git checkout 12c
git checkout 12d

l   join(結合)操作會接收兩個或以上的資料表,找出滿足指定條件的所有資料列組合,並將結合後的資料列插入一個暫時性的資料表,它就是join的結果:
# db.session.query(Post)
指明它是一個會回傳Post物件的查詢指令
# select_from(Follow)
指出這個查詢指令從Follow模型開始處理
# filter_by(follower_id=self.id)
使用追隨者來過濾follows
# join(Post, Follow.followed_id == Post.author_id)
結合filter_by()的結果與Post物件
return db.session.query(Post).select_from(Follow).filter_by(follower_id=self.id).join(Post, Follow.followed_id == Post.author_id)

#
簡化上述這個指令
return Post.query.join(Follow, Follow.followed_id == Post.author_id).filter(Follow.follower_id == seld.id)

l   app/models.py
# ...
class User(UserMixin, db.Model):
    # ...
    #
事後讓使用者成為他們自己的追隨者
    #
Flask Shell下指令,User.add_self_follows()
    @staticmethod
    def add_self_follows():
        for user in User.query.all():
            if not user.is_following(user):
                user.follow(user)
                db.session.add(user)
                db.session.commit()
   
    def __init__(self, **kwargs):
        # ...
        #
在剛建立使用者時,讓他們成為自己的追隨者
        self.follow(self)
    # ...
    #
取得被追隨的文章
    @property
    def followed_posts(self):
        return Post.query.join(Follow, Follow.followed_id == Post.author_id).filter(Follow.follower_id == self.id)

l   app/main/views.py
# ...
#
顯示所有的或追隨的文章
@main.route("/", methods=["GET", "POST"])
def index():
    form = PostForm()
    if current_user.can(Permission.WRITE) and form.validate_on_submit():
        post = Post(body=form.body.data, author=current_user._get_current_object())
        db.session.add(post)
        db.session.commit()
        return redirect(url_for(".index"))
    page = request.args.get("page", 1, type=int)
    show_followed = False
    if current_user.is_authenticated:
        show_followed = bool(request.cookies.get("show_followed", ""))
    if show_followed:
        query = current_user.followed_posts
    else:
        query = Post.query
    pagination = query.order_by(Post.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    return render_template("index.html", form=form, posts=posts, show_followed=show_followed, pagination=pagination)

#
選擇所有的文章
@main.route("/all")
@login_required
def show_all():
    # cookie
只能在回應物件上設定,所以這些路由必須用make_response()建立回應物件
    resp = make_response(redirect(url_for(".index")))
    # set_cookie()
函式的前兩個引數可接收cookie名稱與值,max_age選用引數可設定cookie還有多少秒會過期,不使用這個引數時,cookie會在瀏覽器視窗關閉時過期
    resp.set_cookie("show_followed", "", max_age=30*24*60*60)
    return resp

#
選擇追隨對象的文章
@main.route("/followed")
@login_required
def show_followed():
    resp = make_response(redirect(url_for(".index")))
    resp.set_cookie("show_followed", "1", max_age=30*24*60*60)
    return resp

l   app/templates/index.html
<!-- ... -->
<div class="post-tabs">
    <ul class="nav nav-tabs">
        <li{% if not show_followed %} class="active"{% endif %}><a href="{{ url_for('.show_all') }}">All</a></li>
        {% if current_user.is_authenticated %}
        <li{% if show_followed %} class="active"{% endif %}><a href="{{ url_for('.show_followed') }}">Followed</a></li>
        {% endif %}
    </ul>
    {% include '_posts.html' %}
</div>
<!-- ... -->

第十三章、部落格文章評論-使用者評論(13a)

l   使用者評論:
git checkout 13a

l   app/models.py
# ...
from datetime import datetime
from markdown import markdown
import bleach
from flask_login import UserMixin, AnonymousUserMixin
from . import db, login_manager
# ...
#
從使用者到評論的一對多關係
class User(UserMixin, db.Model):
    # ...
    comments = db.relationship("Comment", backref="author", lazy="dynamic")
# ...
#
從文章到評論的一對多關係
class Post(db.Model):
    # ...
    comments = db.relationship("Comment", backref="post", lazy="dynamic")
# ...
class Comment(db.Model):
    __tablename__ = "comments"
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    body_html = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    disabled = db.Column(db.Boolean)
    author_id = db.Column(db.Integer, db.ForeignKey("users.id"))
    post_id = db.Column(db.Integer, db.ForeignKey("posts.id"))
   
    @staticmethod
    def on_changed_body(target, value, oldvalue, initiator):
        allowed_tags = ["a", "abbr", "acronym", "b", "code", "em", "i", "strong"]
        target.body_html = bleach.linkify(bleach.clean(markdown(value, output_format="html"), tags=allowed_tags, strip=True))

db.event.listen(Comment.body, "set", Comment.on_changed_body)

l   app/main/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp
# ...
class CommentForm(FlaskForm):
    body = StringField("Enter your comment", validators=[DataRequired()])
    submit = SubmitField("Submit")

l   app/main/views.py
from flask import render_template, redirect, url_for, abort, flash, request, current_app, make_response
from flask_login import login_required, current_user
from . import main
from .forms import EditProfileForm, EditProfileAdminForm, PostForm, CommentForm
from .. import db
from ..models import Permission, Role, User, Post, Comment
# ...
#
支援部落格文章評論
@main.route("/post/<int:id>", methods=["GET", "POST"])
def post(id):
    post = Post.query.get_or_404(id)
    form = CommentForm()
    if form.validate_on_submit():
        comment = Comment(body=form.body.data, post=post, author=current_user._get_current_object())
        db.session.add(comment)
        db.session.commit()
        flash("Your comment has been published.")
        # page = -1
這是特殊的頁碼,它會請求評論的最後一頁
        return redirect(url_for(".post", id=post.id, page=-1))
    page = request.args.get("page", 1, type=int)
    if page == -1:
        page = (post.comments.count() - 1) // current_app.config["FLASKY_COMMENTS_PER_PAGE"] + 1
    pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(page=page, per_page=current_app.config["FLASKY_COMMENTS_PER_PAGE"], error_out=False)
    comments = pagination.items
    return render_template("post.html", posts=[post], form=form, comments=comments, pagination=pagination)

l   app/templates/post.html
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - Post{% endblock %}

{% block page_content %}
{% include '_posts.html' %}
<h4 id="comments">Comments</h4>
{% if current_user.can(Permission.COMMENT) %}
<div class="comment-form">
    {{ wtf.quick_form(form) }}
</div>
{% endif %}
{% include '_comments.html' %}
{% if pagination %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.post', fragment='#comments', id=posts[0].id) }}
</div>
{% endif %}
{% endblock %}

l   app/templates/_posts.html
<!-- ... -->
<a href="{{ url_for('.post', id=post.id) }}#comments">
    <span class="label label-primary">{{ post.comments.count() }} Comments</span>
</a>
<!-- ... -->

l   app/templates/_comments.html
<ul class="comments">
    {% for comment in comments %}
    <li class="comment">
        <div class="comment-thumbnail">
            <a href="{{ url_for('.user', username=comment.author.username) }}">
                <img class="img-rounded profile-thumbnail" src="{{ comment.author.gravatar(size=40) }}">
            </a>
        </div>
        <div class="comment-content">
            <div class="comment-date">{{ moment(comment.timestamp).fromNow() }}</div>
            <div class="comment-author"><a href="{{ url_for('.user', username=comment.author.username) }}">{{ comment.author.username }}</a></div>
            <div class="comment-body">
                {% if comment.body_html %}
                    {{ comment.body_html | safe }}
                {% else %}
                    {{ comment.body }}
                {% endif %}
            </div>
        </div>
    </li>
    {% endfor %}
</ul>

第十三章、部落格文章評論-評論審核(13b)

l   評論審核:
git checkout 13b

l   app/main/views.py
@main.route("/moderate")
@login_required
@permission_required(Permission.MODERATE)
def moderate():
    page = request.args.get("page", 1, type=int)
    pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_COMMENTS_PER_PAGE"], error_out=False)
    comments = pagination.items
    return render_template("moderate.html", comments=comments, pagination=pagination, page=page)

#
將評論解除封鎖
@main.route("/moderate/enable/<int:id>")
@login_required
@permission_required(Permission.MODERATE)
def moderate_enable(id):
    comment = Comment.query.get_or_404(id)
    comment.disabled = False
    db.session.add(comment)
    db.session.commit()
    return redirect(url_for(".moderate", page=request.args.get("page", 1, type=int)))

#
將評論封鎖
@main.route("/moderate/disable/<int:id>")
@login_required
@permission_required(Permission.MODERATE)
def moderate_disable(id):
    comment = Comment.query.get_or_404(id)
    comment.disabled = True
    db.session.add(comment)
    db.session.commit()
    return redirect(url_for(".moderate", page=request.args.get("page", 1, type=int)))

l   app/templates/base.html
<!-- ... -->
{% if current_user.can(Permission.MODERATE) %}
<li><a href="{{ url_for('main.moderate') }}">Moderate Comments</a></li>
{% endif %}
<!-- ... -->

l   app/templates/moderate.html
<!--
評論審核模板 -->
{% extends "base.html" %}
{% import "_macros.html" as macros %}

{% block title %}Flasky - Comment Moderation{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Comment Moderation</h1>
</div>
<!--
使用Jinja2set指令來定義模板變數moderate並將它設為True,因為_comments.html模板使用這個變數來確定是否需要轉譯審核功能 -->
{% set moderate = True %}
{% include '_comments.html' %}
{% if pagination %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.moderate') }}
</div>
{% endif %}
{% endblock %}

l   app/templates/_comments.html
<!--
轉譯評論內文 -->
<ul class="comments">
    {% for comment in comments %}
    <li class="comment">
        <div class="comment-thumbnail">
            <a href="{{ url_for('.user', username=comment.author.username) }}">
                <img class="img-rounded profile-thumbnail" src="{{ comment.author.gravatar(size=40) }}">
            </a>
        </div>
        <div class="comment-content">
            <div class="comment-date">{{ moment(comment.timestamp).fromNow() }}</div>
            <div class="comment-author"><a href="{{ url_for('.user', username=comment.author.username) }}">{{ comment.author.username }}</a></div>
            <div class="comment-body">
                {% if comment.disabled %}
                <p><i>This comment has been disabled by a moderator.</i></p>
                {% endif %}
                {% if moderate or not comment.disabled %}
                    {% if comment.body_html %}
                        {{ comment.body_html | safe }}
                    {% else %}
                        {{ comment.body }}
                    {% endif %}
                {% endif %}
            </div>
            {% if moderate %}
                <br>
                {% if comment.disabled %}
                <a class="btn btn-default btn-xs" href="{{ url_for('.moderate_enable', id=comment.id, page=page) }}">Enable</a>
                {% else %}
                <a class="btn btn-danger btn-xs" href="{{ url_for('.moderate_disable', id=comment.id, page=page) }}">Disable</a>
                {% endif %}
            {% endif %}
        </div>
    </li>
    {% endfor %}
</ul>

沒有留言:

張貼留言