iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 24
0
Modern Web

從LINE BOT到資料視覺化:賴田捕手系列 第 24

第 24 天:Flask:表單的操作

第 24 天:Flask:表單的操作

接著,電話那頭換了一個聲音:「嘿,是我。」那是沙粒的聲音。「怎麼了,發生了什麼好事嗎?」
「沙粒,你是沙粒嗎?」
「對,別再大吼大叫了,你喝醉了嗎?」
「恩,聽好,擂台大賽前一天我過去找你,好嗎?幫你把草泥馬的毛給理一理,好嗎?」
「好。你喝醉了,趕快回家休息吧。你在哪裡?誰跟你在一起?」
「沙粒,你聽好,擂台大賽前一天我會過去找你,幫你理一下草泥馬的毛,可以嗎?」
「沒問題啊。你醉了,趕快回家休息啦。你在哪裡?誰跟你在一起?」
「沒人,就只有我、我的影子跟我。」

~節錄自《賴田捕手》第二十章

  昨天學完功能強大的 Jinja2,搭配上第 15 天第 16 天學到的 psycopg2 和 SQL 語法,我們終於能在網站上面清楚的瀏覽每一筆訓練資料了。從資料編號(record_no)、草泥馬的名字(alpaca_name)、訓練內容(training)、訓練長度(duration)、到訓練日期(date),一筆一筆的資料仔仔細細的呈現在眼前,真的很感動呢!原來我們的 LINE 聊天機器人確實有小心翼翼地幫我們把每一筆訓練資料都記錄下來呢,謝謝你貼心又認真的 LINE 聊天機器人。

  大家還記得我們昨天是下了哪一道 SQL 指令來調閱所有的資料呢:

SELECT
*
FROM
    alpaca_training

  既然能夠下達 SQL 指令調閱所有的資料,那能不能只調閱特定的資料並將它們呈現在網路上呢?當然沒問題,我們之前學 SQL 語法可不是隨便學學而已喔:

SELECT
*
FROM
    alpaca_training
WHERE
    alpaca_name = '彼得'
AND training = '嚼食訓練'

  來複習一下吧,上面那段指令,是希望能調閱alpaca_training這個表格(table)中所有alpaca_name'彼得'而且training'嚼食訓練'的資料。是的,我們對 SQL 的基本指令是真的熟稔於心了,但是,當使用者在瀏覽我們的網頁的時候,要怎麼根據使用者的不同需求而下達不同的 SQL 指令呢?我們能不能做出一個網頁,藉由使用者輸入資料,轉換成 SQL 指令並從資料庫提取需要的紀錄之後,再回傳到網頁上呈現給使用者呢?讓使用者可以自由查閱需要的資料,而不是每次都沒頭沒腦的把所有資料全部丟給使用者。
  沒錯,這就是我們今天的目標,學會製作表單(Form),利用表單讓使用者對伺服器發出POST的請求,提取POST的資料並根據其內容做出不同的回應。

HTML 5 的表單(Form)組成

  首先來看一下怎麼再 HTML 5 的檔案中做出表格:

<form method="post" action="/路徑">
    <label for="input_example">請輸入:</label>
    <input type="text" id="input_example" placeholder="在這邊輸入" name="user_input">
    <input type="submit">
</form>
  • 第一行:<form>
      利用一個<form>標籤將一張表單的所有內容都囊括其中。method="post"代表使用者提交表格時,用的請求方法是POST action="/路徑"代表使用者提交表單時,是向"/路徑"這個網址發送請求。

  • 第二行: <label for="input_example">請輸入:</label>
      <label>這個標籤沒有也沒關係。但是為了表單的可讀性,習慣上我們會加上一個<label><input>之前,對<input>的資料內容做個說明。

  • 第三行: <input type="text" id="input_example" placeholder="在這邊輸入" name="user_input">
      利用<input>創造一個供使用者輸入資料的位置。type有相當多種,用來指定資料輸入的方式➀,常用的有<input type="checkbox"><input type="email"><input type="file"><input type="password"><input type="radio"><input type="submit">。而我們這邊用<input type="text">來指定輸入的資料為文字。而id="input_example"則讓<label>可以藉由for="input_example"<input>作出連結。最後一個name="user_input "則是代表填完該表格,發送請求時,伺服器那邊可以用"user_input"這個關鍵字來找尋<input>的資料。

  • 第四行: <input type="submit">
      製作出提交表單的按鈕。

https://ithelp.ithome.com.tw/upload/images/20191002/20120178E8hywiGWMv.png
圖一、基本的表單

  如上圖,這樣就可以做出一個含有輸入文字的最基礎的表單了。不過呢,既然我們都已經使用 Bootstrap 資源來設計網頁了,何不借用 Bootstrap 所提供的元素來美化一下我們的表單呢➁?

<div class="container">
<div class="row">
<div class="col">
<h1>選擇訓練紀錄</h1>
<h2>Postgres Select Query</h2>
  
  <form method="post" action="/select_records">
    <div class="row"><div class="col-1">SELECT</div></div>
    <div class="row"><div class="col-2 offset-1">*</div></div>
    <div class="row"><div class="col-1">FROM</div></div>
    <div class="row"><div class="col-2 offset-1">alpaca_training</div></div>  
    <div class="row"><div class="col-1">WHERE</div></div>

    <div class="form-group row">
      <label for="alpaca_name" class="col-2 offset-1 col-form-label">alpaca_name =</label>
      <div class="col-3">
        <input type="text" class="form-control" id="alpaca_name" name="alpaca_name" placeholder="alpaca_name">
        <small class="form-text">請輸入草尼馬的名字</small>
      </div>
    </div>
    <div class="form-group row">
      <label for="training" class="col-2 offset-1 col-form-label">training =</label>
      <div class="col-3">
        <input type="text" class="form-control" id="training" name="training" placeholder="training">
        <small class="form-text">請輸入訓練的名稱</small>
      </div>
    </div>
    <div class="form-group row">
      <label for="date" class="col-2 offset-1 col-form-label">date =</label>
      <div class="col-3">
        <input type="text" class="form-control" id="date" name="date" placeholder="date">
        <small class="form-text">請輸入訓練的日期</small>
      </div>

    </div>
    
    <button type="submit" class="btn btn-primary">查詢紀錄</button>
</form>
</div>
</div>
</div>

https://ithelp.ithome.com.tw/upload/images/20191002/20120178RIXmBduQ7i.png
圖二、透過 Bootstrap 美化之後的表單

  上面那一個表單,就是我們要用來提供給使用者下達 SQL 指令的表單。設計的理念是這樣的:如果使用者在某一欄(舉例,alpaca_name='吉姆')填了資料,並提交表單,那麼 SQL 應該是這樣下:

SELECT
    *
FROM
    alpaca_training
WHERE
    alpaca_name = '吉姆'

  如果使用者在多個欄位上填了資料,那 SQL 應該是會像這樣:

SELECT
    *
FROM
    alpaca_training
WHERE
    alpaca_name = '吉姆'
AND training = '嚼食訓練'

  如果使用者一個欄位都沒有填,那麼 SQL 就是:

SELECT
    *
FROM
    alpaca_training

  我們希望接下來能寫出一段 Python 程式碼,順利地將使用者填入、提交的表單轉換為 SQL 指令。

  不過在那之前,先重新回頭看看上面那段冗長的 HTML 5 程式碼,有沒有突然眼睛一亮,想著要大喊:「我學過 Jinja2,讓我來簡化它們!」的衝動呢?

<!-- 先宣告一個巨集 -->
{% macro easy_text_form(col_name, note) -%}
  <div class="form-group row">
    <label for="{{ col_name }}" class="col-2 offset-1 col-form-label">{{ col_name }} =</label>
    <div class="col-3">
      <input type="text" class="form-control" id="{{ col_name }}" name="{{ col_name }}" placeholder="{{ col_name }}">
      <small class="form-text">{{ note }}</small>
    </div>
  </div>
{%- endmacro %}


<div class="container">
<div class="row">
<div class="col">

  <h1>選擇訓練紀錄</h1>
  <h2>Postgres Select Query</h2>

<form method="post" action="/select_records">

  <div class="row"><div class="col-1">SELECT</div></div>
  <div class="row"><div class="col-2 offset-1">*</div></div>
  <div class="row"><div class="col-1">FROM</div></div>
  <div class="row"><div class="col-2 offset-1">alpaca_training</div></div>  
  <div class="row"><div class="col-1">WHERE</div></div>

  {{ easy_text_form("alpaca_name", "請輸入草泥馬的名字") }}

  {{ easy_text_form("training", "請輸入訓練的名稱") }}

  {{ easy_text_form("date", "請輸入訓練的日期") }}

  <button type="submit" class="btn btn-dirty-purple">查詢紀錄</button>

</form>

</div>
</div>
</div>

  程式碼變得輕薄短小又漂亮了對吧,Jinja2 就是這麼厲害!

伺服器提取表單(Form)資料

  使用者填好了表單,按下送出,於是向我們的路由"/select_records"發出POST請求,我們應該怎麼做才能夠好好地接住這個請求呢?不囉嗦,直接來看一下程式碼:

@app.route("/select_records", methods=['GET', 'POST'])
def select_records():
    if request.method == 'POST':
        # 偷看一下 request.form 
        print(request.form)
        python_records = web_select_specific(request.form)
        return render_template("show_records.html", html_records=python_records)
    else:
        return render_template("select_records.html")
  • 第一行:@app.route("/select_records", methods=['GET', 'POST'])
      我們先用 flask 提供的方法做出一個路由"/select_records",也就是剛才那份表單提交之後要被送到的位址。此外,我們希望這個路由在接到使用者的GET請求時,能夠回傳一份空白的表單,並且也能夠接受使用者傳來的表單,也就是接受使用者發出的POST請求,因此設定methods=['GET', 'POST']

  • 第三行: if request.method == 'POST':
      如果使用者發出的請求方法是POST,也就是當使用者提交表單時,執行下述程式碼。

  • 第五行: print(request.form)
      讓我們利用print()來偷偷看一下使用者傳回來的表單request.form究竟是什麼。

ImmutableMultiDict([('alpaca_name', alpaca_name 的內容),
                    ('training', training 的內容), 
                    ('date', date 的內容)])

  原來是一種叫做ImmutableMultiDict的物件。這是什麼?我也不知道,只知道這種物件我們可以直接當作字典物件來提取資料。所以,

In [1]: request.form['alpaca_name']
Out[1]: alpaca_name 的內容
In [2]: request.form['training']
Out[2]: training 的內容
In [3]: request.form['date']
Out[3]: date 的內容

  取得資料的方式真的就跟字典一樣!既然拿到了使用者的表單,那我們就來試著按照使用者指示,透過web_select_specific()這個函數(等等來寫)轉換成相對應的 SQL 指令。

  • 第七行: return render_template("show_records.html", html_records=python_records)
      因為我們昨天就已經做好了一個可以接收訓練紀錄並且完美呈現出來的 HTML 5 檔案了,所以今天就繼續借用這個檔案,將根據使用者指定的條件篩選出來的訓練紀錄傳送過去html_records=python_records,今天的任務就圓滿達成了。

  • 第九行: return render_template("select_records.html")
      如果使用者是用POST以外的方法發送請求的話(又,因為我們將請求的方式限定為GETPOST,所以實際上這個請求方法就是GET),那麼就回傳一個空白的表單讓使用者填寫。

產生 SQL 指令

  有了表單,也有了路由,剩下的就是把使用者提交的表單內容轉換成 SQL 指令的函數了。來試著寫寫看吧:

def web_select_specific(condition):
    DATABASE_URL = os.environ['DATABASE_URL']

    conn = psycopg2.connect(DATABASE_URL, sslmode='require')
    cursor = conn.cursor()
    
    condition_query = []
    
    for key, value in condition.items():
        if value:
            condition_query.append(f"{key}={value}")
    if condition_query:
        condition_query = "WHERE " + ' AND '.join(condition_query)
    else:
        condition_query = ''
    
    postgres_select_query = f"""SELECT * FROM alpaca_training {condition_query} ORDER BY record_no;"""
    print(postgres_select_query)
    
    cursor.execute(postgres_select_query)

    table = []
    while True:
        temp = cursor.fetchmany(10)

        if temp:
            table.extend(temp)
        else:
            break

    cursor.close()
    conn.close()

    return table

  程式碼前四行就是之前文章提過,在 Heroku 上透過 psycopg2 連接 Heroku Postgres 的步驟。第五行開始,則是用一些字串跟清單的操作來將表單內容轉換為 SQL 的步驟。

  • 第六行: for key, value in condition.items():
      從使用者請求送過來伺服器的表單condition = request.form是個稱作ImmutableMultiDict的物件,可以把它看成字典物件來提取資料。

  • 第八行: condition_query.append(f"{key}={value}")
      如果value有值,則將字串f"{key}={value}"放入condition_query這個清單當中。

  • 第十行: condition_query = "WHERE " + ' AND '.join(condition_query)
      如果經過了上面一連串for迴圈的操作,condition_query這個清單裡面有項目,就將清單內的項目用' AND '連接起來,並在最前面加上"WHERE ",組成一個字串。

  • 第十二行: condition_query = ''
      若condition_query是一個空的清單,就將condition_query這個標籤另外貼到空字串''上。

  如此一來,我們就夠成功地做出將使用者提交的表單轉換為 SQL 指令的函數了。將 HTML 5 表單、Python 路由、Python 的表單轉換 SQL 函數連接起來,推向 Heroku,來看看我們實作出來的成果吧!

  在 alpaca_name 那個欄位試著輸入「吉姆」看看:

https://ithelp.ithome.com.tw/upload/images/20191002/201201780LLEtZKx5x.png
圖三、在 alpaca_name 的欄位中輸入「吉姆」

https://ithelp.ithome.com.tw/upload/images/20191002/201201785TjDfe5Ur3.png
圖四、在 alpaca_name 的欄位中輸入「吉姆」,結果搞砸了?

  結果出狀況了,為什麼?Heroku 上的工作日誌說:

psycopg2.errors.UndefinedColumn: column "吉姆" does not exist
LINE 1: SELECT * FROM alpaca_training WHERE alpaca_name=吉姆 ORDER BY record_no;

  原來是要輸入「'吉姆'」啊(而且還是單引號的「'吉姆'」不是雙引號的「"吉姆"」):

https://ithelp.ithome.com.tw/upload/images/20191002/20120178ywVsUOugY2.png
圖五、再試一次,在 alpaca_name 的欄位中輸入「'吉姆'」

https://ithelp.ithome.com.tw/upload/images/20191002/20120178N3RrWgN6Zd.png
圖六、原來要輸入「'吉姆'」啊

  成功了。但這也太不貼心了吧?誰知道輸入個資料還有這麼多規則啊。雖然說可以在程式碼上稍作修改,不過,對於不熟悉草泥馬訓練的人們來說,想要查詢訓練內容,卻記不得確切的訓練名稱,反反覆覆輸入了很多次資料才搞定,也是很麻煩的。怎麼辦呢?

重新回到 HTML 5 的表單(Form)組成

  有寫過考卷的人應該知道,填空題不好寫,但改成選擇題可就輕鬆多了。沒錯,既然使用者不一定有辦法提交出「正確」的表格,那我們就改成選擇題,選項我們出,這樣總不會有錯了吧!因此在下面我們要來介紹表單的另一種元素<select>,並重新製作我們的表格。

<form method="post" action="/路徑">
    <label for="input_example">請輸入:</label>
    <select id="input_example" name="user_input">
      <option selected value ="default">預設的選擇</option>
      <option value ="one">其他選擇一</option>
      <option value ="two">其他選擇二</option>
      <option value ="three">其他選擇三</option>
    <input type="submit">
</form>

https://ithelp.ithome.com.tw/upload/images/20191002/20120178K0xV6ox4sE.png
圖七、基本的選單表單

  • 第一行:<form method="post" action="/路徑">
      提交表格時,發出POST請求至"/路徑"

  • 第三行: <select id="input_example" name="user_input">
      用<select>創造一個選單。id="input_example"<select>可以跟<label>做連結,name="user_input"讓提交出去的表單(request.form)可以透過"user_input"來取得該<select>內的資料。

  • 第四行: <option selected value ="default">預設的選擇</option>
      用<option>做出選單的選項們。selected表示開啟表單時,該選項為預設選項。而value ="default"則代表選擇該選項,提交表單時伺服器會接收到的資料。也就是說,如果我們選取這個選項並提交表單,那麼reques.form["user_input"]就是"default"

  • 第五行: <option value ="one">其他選擇一</option>
      更多的選擇。

  這樣我們就做出一個基本的選單表單了。我們需要怎麼樣的選單表格呢?
  我們需要一個選單,在alpaca_name那一欄提供所有可能的選項,在training那一欄提供所有可能的選項,在date那一欄也要提供所有可能的選項。好的,讓我們用 Jinja2 搭配巨集再加上一些些 Bootstrap 的元素:

{% macro easy_option_form(col_name, col_options) -%}
  <div class="form-group row">
    <label for="{{ col_name }}" class="col-2 offset-1 col-form-label">{{ col_name }} =</label>
    <div class="col-3">
      <select class="form-control" id="{{ col_name }}" name="{{ col_name }}">
        <option selected value=''>所有資料</option>
        {% for col_option in col_options %}
        <option value="'{{ col_option }}'">{{ col_option }}</option>
        {% endfor %}
      </select>
    </div>
  </div>
{%- endmacro %}


<div class="container" style="margin: 4em auto">
<div class="row">
<div class="col">
  <h1>選擇訓練紀錄</h1>
  <h2>Postgres Select Query</h2>

<form method="post" action="/select_records">
  <div class="row"><div class="col-1">SELECT</div></div>
  <div class="row"><div class="col-2 offset-1">*</div></div>
  <div class="row"><div class="col-1">FROM</div></div>
  <div class="row"><div class="col-2 offset-1">alpaca_training</div></div>  
  <div class="row"><div class="col-1">WHERE</div></div>

  {{ easy_option_form("alpaca_name", uniques[0]) }}

  {{ easy_option_form("training", uniques[1]) }}

  {{ easy_option_form("date", uniques[2]) }}

  <button type="submit" class="btn btn-dirty-purple">查詢紀錄</button>
</form>

</div>
</div>
</div>

  看一下上面那段程式碼中較關鍵的 Jinja2 巨集easy_option_form()

  • 第七行: {% for col_option in col_options %}
      根據col_options內的項目創造一個迴圈。所謂col_options就是所有的alpaca_name、所有的training、所有的date。因此待會我們要用 Python 寫一個函數,蒐集所有在我們資料庫裡出現過的alpaca_nametrainingdate並傳到這個 HTML 5 檔案。

  • 第八行: <option value="'{{ col_option }}'">{{ col_option }}</option>
      將一個一個col_option放入<option>當中,作出一個一個的選項。而選項的回傳值是"'{{ col_option }}'"。還記得我們剛才討論的單雙引號吧?藉由將回傳值寫在選項裡,讓使用者沒有機會犯下任何錯誤(反之,有錯就是我們造成的)。

  完成了新的表單,那來看看路由可以怎麼寫:

@app.route("/select_records_comfortable", methods=['GET', 'POST'])
def select_records_comfortable():
    if request.method == 'POST':
        print(request.form)
        python_records = web_select_specific(request.form)
        return render_template("show_records.html", html_records=python_records)
    else:
        table = web_select_overall()
        uniques = get_unique(table)
        return render_template("select_records_comfortable.html", uniques=uniques)

  我們把這個可以舒適的選填資料的新表單放在舒適圈"/select_records_comfortable",跟前面的表單比相比,當使用者發出POST請求時,呈現資料的手法是一模一樣的。差別只在使用者發出GET請求時,我們如何回傳給使用者一個選單表單。

第八行: table = web_select_overall()
  把資料庫裡面alpaca_training當中的訓練紀錄都拿出來。

第九行: uniques = get_unique(table)
  所謂的get_unique()就是一個可以尋找所有出現過的alpaca_nametrainingdate的函數。寫起來也不難:

def get_unique(table):
    unique_alpaca_name = {i[1] for i in table}
    unique_training = {i[2] for i in table}
    unique_date = {i[4] for i in table}
    return sorted(unique_alpaca_name), sorted(unique_training), sorted(unique_date)

  用集合自表(set comprehension)就可以做到了。集合當中,同樣的項目不會出現第二次,因此用集合自表就可以得到一個包含所有選項然而選項之間又不會彼此重複的集合。最後用sorted()傳回按照字母排序整理過的集合。

  • 第十行: return render_template("select_records_comfortable.html", uniques=uniques)
      將包含所有選項的集合uniques傳給 HTML 5 檔案"select_records_comfortable.html"使用,讓 Jinja2 能按照所拿到的參數做出所有的選項。如此一來,我們的選單表單就完成了!

  看看成果吧:

https://ithelp.ithome.com.tw/upload/images/20191002/20120178uEQqwbmBTJ.png
圖八、舒適的選單式表單!

  今天的內容就到這裡了。我們不僅再次見證了 Jinja2 創造的奇蹟,還學會了如何在 HTML 5 的檔案中寫出符合需求的表單,了解如何處理使用者發送過來的POST請求、並讀取request.form當中的資料。搭配我們之前在 psycopg2 和 SQL 語法上下的功夫,終於實作出了一個可以根據需求來查詢資料庫內各種不同紀錄的網頁。有興趣的可以連到 "phoebe-takescareof-alpaca.herokuapp.com" 玩玩看。今天相關的程式碼我也會放在 Github 上,想參考看看的人可以到這邊。最後,如果對今天內容有不清楚的地方,講得太籠統的地方,或是想更深入討論的話題,歡迎大家在文章下面留言。謝謝大家!

參考資料

➀ W3School 表單輸入類型介紹
➁ Bootstrap Form 官方說明文件

註:對於此系列文有興趣的讀者,歡迎參考由此系列文擴編成書的 LINE Bot by Python,以及最新的系列文《賴田捕手:追加篇》
第 31 天 初始化 LINE BOT on Heroku
第 32 天 快速回覆 QuickReply 介紹
第 33 天 妥善運用 Heroku APP 暫存空間
第 34 天 妥善運用 LINE Notify 免費推播
第 35 天 製造 Deploy to Heroku 按鈕


上一篇
第 23 天:Flask:Jinja2 傳送變數與操作
下一篇
第 25 天:Flask:登入系統 Flask-Login
系列文
從LINE BOT到資料視覺化:賴田捕手30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
warwertt123
iT邦新手 5 級 ‧ 2022-01-10 15:58:59

您好我是FLASK的蔡逼八新人,
想請問您以上的寫法中,下面這句是否可以僅回傳資料去刷新物件
return render_template("show_records.html", html_records=python_record
而不是使用render_template去刷新整個HTML頁面
因為我在單一HTML有去使用NAV BAR、article以及data-toggle="tab"去分頁
如果使用render_template刷新會回到預設第一頁,
故想說能否直接刷新物件而不跳轉頁面
若問題很奇怪或不周詳,再麻煩您指教,感謝

我要留言

立即登入留言