1. 程式人生 > >Flask Mega-Tutorial 中文教程 V2.0 第9章:分頁

Flask Mega-Tutorial 中文教程 V2.0 第9章:分頁

最近在Flask Web Development作者部落格看到第二版Flask Mega-Tutorial已在2017年底更新,現翻譯給大家參考,希望幫助大家學習flask。

這是Flask Mega-Tutorial系列的第九章,其中我將告訴您如何對資料庫列表進行分頁。

供您參考,以下是本系列文章的列表。

第8章中,我進行了一些必要的資料庫更改,以支援社交網路非常流行的“關注”功能。有了這個功能,我已經準備好刪除開始時放置的最後一塊腳手架,模擬使用者帖子。在本章中,應用程式將開始接受使用者的部落格帖子,並在主頁和個人資料頁面中提供這些部落格文章。

本章的GitHub連結是:Browse

ZipDiff

發表部落格文章

讓我們從簡單的事情開始吧。主頁需要有一個表單,使用者可以在其中鍵入新帖子。首先,我建立一個表單類:

# app/forms.py: Blog submission form.

class PostForm(FlaskForm):
    post = TextAreaField('Say something', validators=[
        DataRequired(), Length(min=1, max=140)])
    submit = SubmitField('Submit')

接下來,我可以將此表單新增到應用程式主頁的模板中:

app/templates/index.html: Post submission form in index template

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.post.label }}<br>
            {{ form.post(cols=32, rows=4) }}<br>
            {% for error in form.post.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

此模板中的更改與以前的表單處理方式類似。最後一部分是新增表單建立和處理邏輯到檢視函式中:

# app/routes.py: Post submission form in index view function.

from app.forms import PostForm
from app.models import Post

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    form = PostForm()
    if form.validate_on_submit():
        post = Post(body=form.post.data, author=current_user)
        db.session.add(post)
        db.session.commit()
        flash('Your post is now live!')
        return redirect(url_for('index'))
    posts = [
        {
            'author': {'username': 'John'},
            'body': 'Beautiful day in Portland!'
        },
        {
            'author': {'username': 'Susan'},
            'body': 'The Avengers movie was so cool!'
        }
    ]
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

讓我們逐一解讀此檢視​​函式中的更改:

  • 匯入Post和PostForm
  • 關聯index檢視函式的兩個路由除了GET都新增POST請求,以便此檢視函式處理接收的表單資料。
  • 表單處理邏輯將在資料庫中插入一條新的post記錄。
  • 模板接收新增form物件,以便渲染文字輸入框。

在繼續之前,我想提一些與處理Web表單相關的重要事項。請注意,在處理表單資料後,我通過重定向到主頁來結束請求。我可以輕鬆地跳過重定向,並允許函式繼續向下進入模板渲染部分,因為這已經是主頁檢視函數了。

那麼,為什麼重定向呢?標準做法是使用重定向響應Web表單提交生成的POST請求。這有助於緩解在Web瀏覽器中如何實現重新整理命令的煩惱。當您點選重新整理鍵時,所有Web瀏覽器都會重新發出最後一個請求。如果帶有表單提交的POST請求返回常規響應,則重新整理將重新提交表單。由於這是意料之外的,瀏覽器將要求使用者確認重複的提交,但大多數使用者將無法理解瀏覽器詢問的內容。但是如果使用重定向來相應POST請求,則現在指定瀏覽器傳送GET請求以獲取重定向中指定的頁面,所以現在最後一個請求不再是POST 請求,重新整理命令就能以更可預測的方式工作。

這個簡單的技巧稱為 Post/Redirect/Get模式。當用戶在提交Web表單後無意中重新整理頁面時,它可以避免插入重複的帖子。

顯示部落格帖子

如果你還記得,我建立了一些模擬的部落格文章,我已經在主頁上顯示了很長時間。這些模擬物件在index檢視函式中顯式建立為一個簡單的Python列表:

    posts = [
        { 
            'author': {'username': 'John'}, 
            'body': 'Beautiful day in Portland!' 
        },
        { 
            'author': {'username': 'Susan'}, 
            'body': 'The Avengers movie was so cool!' 
        }
    ]

但是現在我在User模型中有了followed_posts()方法,它返回給定使用者想要檢視的帖子的查詢列表。所以現在我可以用真正的帖子替換模擬帖子:

# app/routes.py: Display real posts in home page.

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    posts = current_user.followed_posts().all()
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

User類的followed_posts方法返回一個SQLAlchemy查詢物件,該物件被配置為從資料庫中獲取使用者感興趣的帖子。在此查詢呼叫all()會觸發其執行,返回值為包含所有結果的列表。所以我最終得到一個與我迄今為止使用過的模擬帖子非常相似的結構。它們非常接近,模板甚至不需要改變。

更容易找到要關注的使用者

相信你已經留意到了,應用程式並沒有一個很好的途徑來讓使用者找到其他使用者來進行關注。事實上,現在根本沒有辦法檢視有哪些使用者存在。我將通過一些簡單的更改來解決這個問題。

我要建立一個新頁面,稱之為“Explore”頁面。此頁面看起來像主頁,但卻不會僅顯示來自已關注使用者的帖子,而是顯示來自所有使用者的全部帖子列表。新的瀏覽檢視函式如下:

# app/routes.py: Explore view function.

@app.route('/explore')
@login_required
def explore():
    posts = Post.query.order_by(Post.timestamp.desc()).all()
    return render_template('index.html', title='Explore', posts=posts)

您是否注意到此檢視函式中的奇怪之處?render_template()引用了我在應用程式的主頁面使用的index.html模板。由於這個頁面與主頁面非常相似,所以我決定重用該模板。但與主頁面的一個區別是,在Explore頁面中我不需要一個表單來寫部落格帖子,所以在這個檢視函式中我沒有在模板呼叫中包含form引數。

為了防止index.html模板在嘗試渲染不存在的Web表單時崩潰,我將新增一個條件,只有在傳入的引數不為空時才渲染表單:

app/templates/index.html: Make the blog post submission form optional

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% if form %}
    <form action="" method="post">
        ...
    </form>
    {% endif %}
    ...
{% endblock %}

這個頁面也需要新增到導航欄中:

app/templates/base.html: Link to explore page in navigation bar.

<a href="{{ url_for('explore') }}">Explore</a>

還記得我在第6章介紹的用於在使用者個人資料頁面中渲染部落格帖子的_post.html子模板嗎?這是一個包含在使用者配置頁面模板中的小模板,它獨立於其它模板,因此也可以被其他模板中呼叫。我現在要對它進行一些改進,將部落格文章作者的使用者名稱顯示為一個連結:

app/templates/_post.html: Show link to author in blog posts.

    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>
                <a href="{{ url_for('user', username=post.author.username) }}">
                    {{ post.author.username }}
                </a>
                says:<br>{{ post.body }}
            </td>
        </tr>
    </table>

我現在可以使用此子模板在主頁和瀏覽頁面渲染部落格帖子:

app/templates/index.html: Use blog post sub-template.

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    ...

子模板需要存在一個post變數,才能正常工作。該變數是index模板的迴圈變數的命名方式。

通過這些微小的變更,應用程式的使用者體驗得到了顯著改善。現在,使用者可以訪問探索頁面以閱讀來自陌生使用者的部落格帖子,並根據這些帖子找到要關注的新使用者,只需單擊使用者名稱即可訪問該個人資料頁面。太棒了,不是嗎?

此時我建議您再次嘗試該應用程式,以便體驗這些最後的使用者介面改進。

ch09-explore

部落格帖子的分頁

該應用程式看起來更完善了,但顯示主頁中所有已關注使用者的帖子將很快成為一個問題。如果使用者有成千上萬條關注的使用者帖子,會發生什麼呢?如果一百萬呢?可以想象,管理如此龐大的帖子列表將非常緩慢且低效。

為了解決這個問題,我打算對帖子列表進行分頁。這意味著,一開始我只顯示有限數量的帖子,幷包含用於瀏覽整個帖子列表的連結。Flask-SQLAlchemy本身支援使用paginate()查詢方法進行分頁。例如,如果我想獲得使用者的前20個帖子,我可以用以下內容替換all()終止查詢的呼叫:

>>> user.followed_posts().paginate(1, 20, False).items

可以在Flask-SQLAlchemy的任何查詢物件上呼叫paginate方法。它需要三個引數:

  • 頁碼,從1開始
  • 每頁的顯示列表長度
  • 錯誤標誌。如果True,當請求超出範圍的頁面時,404錯誤將自動返回給客戶端。如果False,則會返回一個空列表。

paginate返回值是一個Pagination物件。此物件的items屬性包含所請求頁面中的專案列表。我將在稍後討論Pagination物件中的其它一些用途。

現在讓我們考慮如何在index()檢視函式中實現分頁。我可以首先向應用程式新增一個配置項,以確定每頁顯示的專案列表長度。

# config.py: Posts per page configuration.

class Config(object):
    # ...
    POSTS_PER_PAGE = 3

儲存這些應用範圍的“可控變數”到配置檔案是一個好主意,因為這樣我調整時只需去一個地方。在最終的應用程式中,我當然讓每頁顯示的列表長度大於3,但是對於測試,使用小數字更方便。

接下來,我需要決定如何將頁碼合併到應用程式的URL中。一種相當常見的方法是使用查詢字串引數來指定可選的頁碼,如果沒有給出,則預設為第1頁。以下是一些示例網址,展示了我如何實現這一點:

  • 第1頁,隱含:http://localhost:5000/index
  • 第1頁,顯式: http://localhost:5000/index?page=1
  • 第3頁: http://localhost:5000/index?page=3

要訪問查詢字串中給出的引數,我可以使用Flask的request.args物件。您已在第5章中看到過這一點,在其中我用Flask-Login中實現了使用者登入的可包含一個next查詢字串引數的URL 。

下面你可以看到我如何在index和explore檢視函式新增分頁:

# app/routes.py: Followers association table

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items)

@app.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template("index.html", title='Explore', posts=posts.items)

通過這些更改,兩個路由確定了要顯示的頁碼,可以是page查詢字串引數,也可以是預設值1,然後使用paginate()方法僅檢索所需的結果頁面。決定頁面大小的POSTS_PER_PAGE配置項是通過app.config物件中獲取的。

請注意這些更改是非常容易,以及每次更改時對程式碼的影響都很小。我正在嘗試編寫應用程式每個部分的時候,不對其他部分如何工作做出任何假設,這使我能夠編寫更易於擴充套件和測試且兼具模組化和健壯性的應用程式,並且不太可能出現故障或bugs。

嘗試一下分頁功能。首先要確保你有三篇以上的博文。在瀏覽頁面中更方便測試,因為這個頁面顯示了所有使用者的帖子。您現在只會看到最近的三篇帖子。如果要檢視接下來的三篇,請在瀏覽器的位址列中鍵入 http://localhost:5000/explore?page=2 

頁面導航

下一處更改是在部落格帖子列表底部新增連結,允許使用者導航到next和/或previous。還記得我提到過,呼叫paginate()的返回值是 Flask-SQLAlchemy中Pagination類的物件?到目前為止,我已經使用了此物件的items屬性,其中包含為所選頁面檢索的使用者帖子列表。但是,Pagination物件還有一些其他的屬性在構建分頁連結時很有用:

  • has_next:如果當前頁面後面至少還有一頁,則為True
  • has_prev:如果在當前頁面之前至少還有一頁,則為True
  • next_num:下一頁的頁碼
  • prev_num:上一頁的頁碼

通過這四個元素,我可以生成上一頁和下一頁連結,並將它們傳遞給模板進行渲染:

# app/routes.py: Next and previous page links.

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('index', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)

 @app.route('/explore')
 @login_required
 def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('explore', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template("index.html", title='Explore', posts=posts.items,
                          next_url=next_url, prev_url=prev_url)

只有在上一頁或者下一頁方向上有頁面時,才會將這兩個檢視函式中的next_urlprev_url通過url_for()設定為返回的URL 。如果當前頁面位於帖子列表的末尾或者開頭,則Pagination物件的屬性has_nexthas_prev屬性將是False,並且在這種情況下,上一頁或者下一頁方向上的連結將被設定為None

我之前沒有討論的url_for()函式的一個有趣的地方是你可以向它新增任何關鍵字引數,如果這些引數的名稱沒有直接在URL中引用,那麼Flask會將它們作為查詢字串引數包含在URL中。

分頁連結被設定在index.html模板中,所以現在讓我們在頁面帖子列表的正下方渲染它們:

app/templates/index.html: Render pagination links on the template.

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}
    ...

此更改會在首頁和探索頁面上的帖子列表下新增連結。第一個連結標記為“Newer posts”,它指向上一頁(請記住,我顯示的帖子是按他們建立的時間倒序排列的,因此第一頁是最新的帖子)。第二個連結標記為“Older posts”,並指向帖子的下一頁。如果這兩個連結中的任何一個是None,則通過條件過濾將其從頁面中省略。

ch09-pagination

使用者個人資料頁面中的分頁

首頁頁面的更改已完成。但是,使用者個人資料頁面中也有一個帖子列表,其中僅顯示來自個人資料擁有者的帖子。為了保持一致,應更改使用者個人資料頁面,以匹配首頁頁面的分頁樣式。

我首先更新使用者個人資料檢視功能,其中仍然是一個模擬使用者的帖子列表。

# app/routes.py: Pagination in the user profile view function.

@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get('page', 1, type=int)
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('user', username=user.username, page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('user', username=user.username, page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('user.html', user=user, posts=posts.items,
                           next_url=next_url, prev_url=prev_url)

為了獲取使用者的帖子列表,我利用了SQLAlchemy根據User模型中的db.relationship()定義而設定的關係查詢user.posts。我使用這個查詢並新增一個order_by()子句,以便我先獲得最新的帖子,然後像我對首頁和探索頁面中的帖子那樣進行分頁。請注意,url_for()函式生成的分頁連結需要額外的username引數,因為它們指向使用者個人資料頁面,這個頁面依賴使用者名稱作為URL的動態引數。

最後,對user.html模板的更改與我在首頁頁面上所做的更改相同:

app / templates / user.html:使用者個人資料模板中的分頁連結。

<!-- app/templates/user.html: Pagination links in the user profile template. -->
    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}

完成分頁功能的實驗後,可以將POSTS_PER_PAGE配置項設定為更合理的值:

# config.py: Posts per page configuration.

class Config(object):
    # ...
    POSTS_PER_PAGE = 25