Django -- 從平凡到超凡

第 9 章    表單

∗ 表單 (Form):用來蒐集使用者的資訊

▸ Django 的表單類別可以自動產生 HTML 表單,因此不需寫太多 HTML 程式碼

▸ 利用表單完成資料之增讀改刪查功能:Create, Read, Update, Delete, Search (CRUDS)

▸ Django表單功能:

✶ 利用自動產生的小工具來顯示 HTML 表單,例如文字欄位或日期選擇器
✶ 依據所指定的規則來驗證表單資料
✶ 如果表單資料有誤,會自動重新顯示表單

(1) 表單格式

∗ HTML 表單語法如下:

<form method=... action=...>
  ...
</form>

methodgetpost

action:表單擬送達的 URL

∗ 常用 HTML 表單欄位

▸ 文字輸入 (Text input):<input type="text" name=...>

▸ 密碼輸入 (Password input):<input type="password" name=...>

▸ 文字區塊 (Text area):<textarea rows=... cols=... name=...> ... </textarea>

▸ 單選 (Radio choice):

<input type="radio" name=... value=... checked> ...
<input type="radio" name=... value=...> ...

▸ 勾選框 (Check box,複選):

<input type="checkbox" name=... value=...> ...
<input type="checkbox" name=... value=... checked> ...

▸ 下拉選單 (Drop-down select box):

<select name=...>
  <option value=...> ... </option>
  <option value=... selected> ... </option>
  ...
</select>

▸ 檔案上載 (File upload)表單:

<form ... enctype="multipart/form-data">
  <input type="file" value=...>
<form>

▸ 影像按鈕 (Image button):<input type="image" src=...>

▸ 隱藏輸入 (Hidden input):<input type="hidden" name=... value=...>

▸ 按鈕 (Button):<button> ... </button>

▸ 表單送出 (Form submit) 按鈕:<input type="submit" value=...>

∗ Django 表單處理流程

▸ 在 app 目錄裡建立 forms.py 模組

▸ 在 forms.py 中建立表單類別 (Form class),如果表單裡的欄位來自 Model, 則繼承 forms.ModelForm,如果表單與 Model 無關, 則繼承 forms.Form

▸ 如果需要,可以客製化 Django 表單

▸ 建立表單處理的 URL 對應

▸ 撰寫 views 程式來處理表單,包括:呈現及儲存表單資料、顯示錯誤訊息等

▸ 建立範本來呈現 HTML 表單

(2) 建立 Django 表單類別

∗ Django 表單

▸ 依據 Article model 建立 Django 表單 ArticleForm

article/forms.py
from django import forms
from article.models import Article


class ArticleForm(forms.ModelForm):
    title = forms.CharField(label='標題', max_length=128)
    content = forms.CharField(label='內容', widget=forms.Textarea)

    class Meta:
        model = Article
        fields = ['title', 'content']
✶ 首先匯入 formsArticle model
ArticleForm()Article model 的表單, 因此繼承 forms.ModelForm ,有兩個輸入欄位 (titlecontent)
# title:字元欄位 (CharField),欄位標籤為「標題」,最長 128 個字元, Django 預設 CharField 使用 <input type="text" ...> 輸入欄位
# content:字元欄位 (CharField), 欄位標籤為「內容」,Widget 為表單小工具,用來設定頁面中的輸入模式 (此例為 <textarea ...>...</textarea>)
# 在詮釋資料中,model = Article:說明表單來自於 Article model,Django 會檢查表單和 Model 是否一致
# fields = ['title', 'content']:以串列指定表單所使用的欄位,有三種寫法:
- fields = [...]:包含所需要的 Model 欄位(正面表列)
- exclude = [...]:排除某些 Model 欄位(負面表列)
- fields = '__all__':所有 Model 欄位
Django 表單常用欄位及小工具

▸ 常用欄位:

CharField:字元
IntegerField:整數
FloatField:浮點數
BooleanField:布林
DateField:日期
DateTimeField:日期時間
ChoiceField:單選
MultipleChoiceField:複選
ModelChoiceField:單選 (選項來自 Model)
URLField:網址
EmailField:電郵

▸ 常用 Widget:

widget=forms.TextInput():單行文字輸入框
widget=forms.TextInput(attrs={'type':'date'}):單行日期輸入框
widget=forms.PasswordInput():單行密碼輸入框
widget=forms.NumberInput():單行數字輸入框
widget=forms.Textarea():多行文字輸入框
widget=forms.RadioSelect():單選鈕
widget=forms.Select():下拉式單選清單
widget=forms.ChoiceField():複選框
widget=forms.CheckboxSelectMultiple():多選框

∗ Django 表單的繼承

▸ 如果 Django 表單內容是來自於 Model,則繼承 forms.ModelForm

▸ 一般 Django 表單則繼承 forms.Form, 而且不需要 class Meta 類別,例如:

from django import forms

class NameForm(forms.Form):
    name = forms.CharField(label='姓名', max_length=30)

(3) 新增文章

∗ 使用者填寫表單的流程:

  1. 在部落格頁面點擊連結進入表單:發出 GET 請求,頁面顯示空白表單
  2. 填寫資料
  3. 點擊「送出」按鈕:發出 POST 請求,儲存資料,執行完畢回到部落格頁面

∗ 新增文章功能

▸ 規劃新增文章之 URL 的格式為 article/articleCreate/

article/urls.py:
...
urlpatterns = [
    url(r'^$', views.article, name='article'),
    url(r'^articleCreate/$', views.articleCreate, name='articleCreate'),
]
✶ 處理函式為 views.articleCreate(), 並將此 URL 對應命名為 articleCreate

▸ views 程式

article/views.py
...

def article(request):
    ...


def articleCreate(request):
    '''
    Create a new article instance
        1. If method is GET, render an empty form
        2. If method is POST, perform form validation and display error messages if the form is invalid
        3. Save the form to the model and redirect the user to the article page
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')
✶ 暫時先顯示 article.html 頁面,其餘程式之後再補上

▸ 頁面加入新增文章按鈕

article.html
...
<h1>歡迎來到我的部落格</h1>
<br>
<p><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
{% for items in itemsList %}
...
✶ 點擊此 <a> 標籤將產生 GET 請求:要求系統顯示空白表單讓使用者填寫資料

▸ 撰寫 CSS 將 <a> 連結樣式變為按鈕:此樣式可共用,因此放在 main.css

main.css:
...

ul#menu li {
  display: inline-block;
}

/* Button */
.btn {
  display: inline-block;
  color: black;
  font-size: 0.8em;
  padding: 0.5em 1em;
  text-decoration: none;
  border: thin solid gray;
  background: linear-gradient(#f7f7f7, #dedede);
}
display:設定為行內區塊元素特性
color:黑色文字
font-size:設定字體大小
padding:讓按鈕看起來寬扁
text-decoration:去除連結底線
border:邊界為灰色細實線
background:背景為線性漸層
--> 結果:
新增文章按鈕

∗ 表單處理流程

表單流程

▸ 使用者在頁面點擊「新增文章」按鈕:瀏覽器發出 GET request 傳送到後端,系統產生未綁定表單 (Unbound form,亦即空白表單) 傳送到瀏覽器呈現

▸ 使用者填寫資料後送出表單:瀏覽器發出 POST request,系統產生綁定表單 (Bound form, 亦即將使用者填入的資料綁定在表單類別)

▸ 驗證表單資料:若正確則儲存表單資料至資料庫,否則將綁定表單傳送到瀏覽器再次顯示, 表單內含原先輸入的資料與錯誤訊息,讓使用修正錯誤部份

∗ 處理表單的 GET 請求

article/views.py

from django.shortcuts import render

from article.models import Article, Comment
from article.forms import ArticleForm


def article(request):
    ...
 

def articleCreate(request):
    ...
    # TODO: finish the code
    return render(request, 'article/article.html')
    template = 'article/articleCreate.html'
    if request.method == 'GET':
        return render(request, template, {'articleForm':ArticleForm()})

▸ 匯入 ArticleForm:Django 表單類別,用來產生 HTML 表單

articleCreate.html:表單範本

if request.method == 'GET':HTTP request 的方法如果是 GET ,表示使用者點選 <a> 按鈕或在瀏覽器直接輸入 URL 方式發出請求,系統顯示一個空白表單讓使用者輸入資料

--> 因為此函式同時處理 GET 與 POST 請求,故需判斷請求的型態,後續會再加上 POST 請求的處理程式

{'articleForm':ArticleForm()}: 利用 ArticleForm() 產生一個 Django 表單實例 (Instance), 並指派給範本變數 articleForm,然後傳送給範本

--> 以上述 ArticleForm() 無參數方式呼叫 Django 表單類別會產生一個空白表單,此種表單稱為未綁定表單 (Unbound form),也就是說表單沒有綁定任何資料, 是個空白表單

∗ 建立新增文章之範本

article/templates/article/articleCreate.html

<!doctype html>
{% load staticfiles %}
<html>
  <head>
    <title>部落格</title>
    <meta charset="utf-8">
    <link rel="stylesheet" href="{% static 'main/css/main.css' %}">
    <link rel="stylesheet" href="{% static 'article/css/article.css' %}">
  </head>
  <body>
    {% include 'main/menu.html' %}
    <h2>新增文章</h2>
    <form method="post" action="{% url 'article:articleCreate' %}">
      {% csrf_token %}
      {{ articleForm.as_p }}
      <input type="submit" value="送出">
    </form>
  </body>
</html>

▸ 連結兩個 CSS 檔案:main.cssarticle.css

form 的方法為 postaction 使用 {% url 'article:articleCreate' %} 之具名 URL 格式

{% csrf_token %}:加入表單安全機制,Django 要求所有 HTML 表單都必須有防止跨網站偽造請求 (Cross-site request forgery, CSRF) 之機制,以確保網站安全

✶ 跨網站偽造請求:當 Django 送出空白表單時,會將一個亂數送出讓瀏覽器存在 Cookie 中,等到使用者送出表單時, 瀏覽器會連同屬於該部伺服器的 Cookie 一併送出,伺服器端就可以驗證是否是由相同瀏覽器所送出的表單
✶ 因此將整個 HTML 碼複製然後到另一種瀏覽器或另一部電腦會無法送出表單

{{ articleForm.as_p }}: 讓 Django 自動產生 HTML 表單,表單欄位以段落方式呈現,因此每個欄位會以 <p></p> 包住 (as_p:as paragraph)

<input type="submit" value="送出">:表單送出按鈕

▸ 結論:開發者僅需撰寫 <form ...> 標籤以及 <input type="submit" ...> 送出按鈕,其餘欄位由 Django 自動產生

∗ 加上輸入框的樣式

article/static/article/css/article.css

...

.commentTime {
  ...
}

input[type=text] {
  padding: 0.4em;
}

▸ 讓輸入框寬敞一點

∗ 測試

▸ 點擊「新增文章」按鈕,產生空白表單

新增文章

▸ 在瀏覽器頁面點擊右鍵檢視 HTML 原始碼,可看到 Django 自動產生以下 HTML 表單

...
<form method="post" action="/article/articleCreate/">
  <input type='hidden' name='csrfmiddlewaretoken' value='...' />
  <p>
    <label for="id_title">標題:</label>
    <input id="id_title" maxlength="128" name="title" type="text" required />
  </p>
  <p>
    <label for="id_content">內容:</label>
    <textarea cols="40" id="id_content" name="content" rows="10" required></textarea>
  </p>
  <input type="submit" value="送出">
</form>
...
<input type="hidden" name="csrf..." value=...>: 隱藏標籤,其中 csrfmiddlewaretoken 的一長串亂數值是 Django 產生, 在 HTML 表單送出時會一併傳送,以便比對填寫資料的瀏覽器是否就是送出資料的瀏覽器
<p></p> 標籤:因為範本裡指定 {{ articleForm.as_p }}, 因此每個欄位都被 <p> 包住
<lable for=...></lable>:在表單呈現的欄位名稱, 文字內容取自 ArticleForm 類別裡的 lable 資料
<input id=...>:輸入欄位,且自動產生下列資料
# id="id_<fieldName>":Django 自動產生 id, 其值就是 Model 欄位名稱冠上 id_ 前置字元
# max_length="128":其值取自 ArticleForm 類別裡的 max_length 資料
# name="<fieldName>":其值取自 ArticleForm 類別裡的欄位名稱

∗ 亦可在 views 程式觀察 Django 所產生的表單內容

▸ 在 articleCreate() 函式中印出 ArticleForm 類別的內容:

def articleCreate(request):
    ...
    if request.method == 'GET':
        print(ArticleForm())
        return render(request, template, {'articleForm':ArticleForm()})
✶ 結果如下,可見 Djano 所產生表單的預設的結構是表格:
<tr>
  <th><label for="id_title">標題:</label></th>
  <td><input id="id_title" maxlength="128" name="title" type="text" required /></td>
</tr>
<tr>
  <th><label for="id_content">內容:</label></th>
  <td><textarea cols="40" id="id_content" name="content" rows="10" required></textarea></td>
</tr>
其他 Django 表單格式

Django 另外還有兩種表單格式:

∗ 處理表單的 POST 請求

article/views.py

...

def articleCreate(request):
    ...
    if request.method == 'GET':
        return render(request, template, {'articleForm':ArticleForm()})
    # POST
    articleForm = ArticleForm(request.POST)
    if not articleForm.is_valid():
        return render(request, template, {'articleForm':articleForm})
    articleForm.save()
    return article(request)

▸ POST 請求來自於 <form method="post" ...>, 表示使用者已填好資料並按下「送出」按鈕以送出表單

articleForm = ArticleForm(request.POST)

request.POST 是使用者在表單裡所填的資料,透過 <form method="post" ...> 送到後端
✶ 以 request.POST 為參數呼叫 Django 表單類別所產生的表單稱為綁定表單 (Bound form),也就是說該表單已綁定使用者輸入的資料
✶ 利用 print(request.POST) 看表單送出的內容,如下:
<QueryDict: {'csrfmiddlewaretoken': ['...'], 'content': ['...'], 'title': ['...']}>
# 可見 Django 是利用 Python 字典來儲存使用者送出的資料,每份資料的值以串列儲存 (可能多個)

articleForm.is_valid():利用 Django 表單方法 is_valid() 來驗證使用者所輸入的資料格式是否正確

✶ 如果資料不正確,將綁定表單 articleForm (內含使用者輸入的資料及錯誤訊息) 再次顯示給使用者,如此使用者就不需要重新輸入全部資料, 只要修正錯誤部份即可
✶ 如果資料正確,呼叫表單方法 save() 將資料存入資料庫

▸ 最後呼叫 article(request) 函式回到部落格頁面

--> 測試:在文章頁面按下「新增文章」,輸入文章標題及內容,再按下「送出」紐, 可看到文章已新增完成

輸入文章資料      新增文章完成

if ... return 指令

以上 Request method 的判斷:

  if request.method == 'GET':
      return render(request, template, {'articleForm':ArticleForm()})
  # POST
  articleForm = ArticleForm(request.POST)
  ...

不要寫成以下格式,以免階層過深:

  if request.method == 'GET':
      return render(request, template, {'articleForm':ArticleForm()})
  else:    # POST
      articleForm = ArticleForm(request.POST)
      ...

--> Zen of Python: Flat is better than nested.

因此,只要在函式中有 if ... return 指令時, 後續程式都不要再使用 else

GET 與 POST

HTTP request 有兩個基本方法:GET 與 POST

▸ GET:

✶ 發出 GET 請求的方式:
# 使用者直接在瀏覽器的 URL 欄位中輸入網址
# HTML的 <a>, <img>, <link>, <script> 等連結資源的標籤
# 使用 GET 方法的表單:<form method="get" ...>
# JavaScript 或 jQuery 程式所發出的 GET 請求
✶ HTML 表單送出時,欄位及其值會以配對方式顯示在 URL request 裡,例如:
article/articleSearch/?id=20&username=myname
✶ 使用時機:傳送的資料不會改變伺服器資料的狀態 (亦即不寫入資料)

▸ POST:

✶ 發出 POST 請求的方式:
# 使用 POST 方法的表單:<form method="post" ...>
# JavaScript 或 jQuery 程式所發出的 POST 請求
✶ 使用時機:傳送的資料會改變伺服器資料的狀態 (亦即資料會寫入資料庫)

∗ 使用者在瀏覽器重複上次動作 (或按下 F5 功能鍵) 的問題

▸ 在使用者新增資料後會顯示部落格頁面,如果此時使用者按下 F5 按鈕就會重複上次動作 (重新整理頁面), 也就是再次送出表單,此時瀏覽器會發出警告,如果繼續就會重複送出表單,造成相同資料再次儲存

--> 測試:新增一篇文章後,立刻按下鍵盤 F5 功能按鈕 (此為 Chrome 的範例,各種瀏覽器的訊息可能有所不同)
按F5

▸ 解決方案:Post/Redirect/Get 設計模式

✶ 在 Post request 之後,先轉址到目的網頁,然後再發出一個 GET request, 如此使用者重整頁面所重複的指令會是 GET 而不是 POST (多次執行 GET 不會造成問題),流程如下:
轉址
✶ Django 的 Post/Redirect/Get 實作很簡單:利用 redirect() 函式
article/views.py
from django.shortcuts import render, redirect

...

def articleCreate(request):
    ... 
    if not articleForm.is_valid():
        return render(request, template, {'articleForm':articleForm})
    articleForm.save() 
    return article(request)
    return redirect('article:article')
# 匯入 redirect
# 以具名 URL 格式當作參數來呼叫 redirect 函式,即可完成 Post/Redirect/Get 機制
--> 測試:送出表單後再按 F5 按鈕,只會刷新頁面,不會重送表單

∗ 訊息框架 (Messages framework)

▸ 在使用者完成資料儲存時,通常會回覆訊息,例如:「文章已新增」,因此需要將此訊息送至頁面

▸ Django 傳送資料到頁面的方式為利用範本變數,然後由範本引擎來呈現,但 redirect() 函式無法傳送範本變數

--> 解決方案:利用訊息框架將資料存在框架中,然後由範本引擎來呈現 (在 settings.py 裡的 INSTALLED_APPSdjango.contrib.messages 項目)

▸ 加入訊息

article/views.py
from django.shortcuts import render, redirect
from django.contrib import messages

...

def articleCreate(request):
    ...
    articleForm.save()
    messages.success(request, '文章已新增')
    return redirect('article:article')

▸ 訊息型態分為 debug, info, success, warning, 及 error 五種,在 views 程式中的使用方式為 messages.debug('...'), messages.info('...'), messages.success('...'), ...

▸ 在範本中的訊息是以串列儲存 (可以有許多訊息),因此需要使用 for 迴圈顯示,訊息的型態則儲存在 message.tags 裡:

{% for message in messages %}
  <p class="{{ message.tags }}">{{ message }}</p>
{% endfor %}
✶ 為不同訊息型態加上 CSS 樣式:
main.css
...

/* Button */
.btn {
  ...
}

/* Messages */
.success, .error {
  padding: 1em 0 1em 3em;
}

.success {
  background-color: #ddffdd;
  border: thin solid green;
}

.error {
  background-color: #fbd9d8;
  border: thin solid red;
}
# 成功訊息顯示淺綠色,錯誤訊息顯示淺紅色

▸ 在 article/article.html 中顯示訊息:

    ...
    {% include 'main/menu.html' %}
    <h1>歡迎來到我的部落格</h1>
    {% for message in messages %}
      <p class="{{ message.tags }}">{{ message }}</p>
    {% endfor %}
    <p><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
    ...
✶ 測試:送出表單後會顯示訊息
articleCreateMessage

∗ 各個頁面均可匯入訊息顯示

▸ 由於許多範本都可能使用訊息框架,因此可在 main app 中加入 messages.html,供所有 app 匯入使用:

main/templates/main/messages.html:
{% for message in messages %}
  <p class="{{ message.tags }}">{{ message }}</p>
{% endfor %}

▸ 在 article.html 中匯入 messages.html

article.html:
    ...
    {% include 'main/menu.html' %}
    {% for message in messages %}
      <p class="{{ message.tags }}">{{ message }}</p>
    {% endfor %}
    {% include 'main/messages.html' %}
    <p><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
    ...

∗ 放棄編輯

▸ 可再加入「放棄」按鈕,讓使用者可以放棄編輯,直接回到部落格頁面

article/articleCreate.html:
      ...
      <input class="btn" type="submit" value="送出">
      <a class="btn" href="{% url 'article:article' %}">放棄</a>
    </form>  
    ...
✶ 同時也在 <input ...> 標籤加上 class="btn" 樣式類別,讓兩個按鈕外觀一樣,但 <input> 在不同瀏覽器有不同預設字型, 因此與 <a> 並列時可能因字體大小差距會導致按鈕高度不同
✶ 解決方案:將字體統一
/* Button */
.btn {
  font-family: Arial;
  display: inline-block;
}

(4) 閱讀文章

▸ 由於文章可能很長,因此在部落格文章列表時,目前僅顯示部份文字

▸ 應提供使用者點擊文章標題後,可以閱讀整篇文章

∗ 在文章標題加上連結,點擊後可閱讀整篇文章

▸ 規劃 URL 格式為 article/articleRead/<articleId>/, 其中 articleId 是該物件在資料庫裡的 id (回顧第 7 章:「資料庫遷移」一節)

article/urls.py
...
urlpatterns = [
    ...
    url(r'^articleCreate/$', views.articleCreate, name='articleCreate'),
    url(r'^articleRead/(?P<articleId>[0-9]+)/$', views.articleRead, name='articleRead'),
]
(?P<variable>pattern):URL 參數的名稱與模式, Django 利用此方式在 URL 中傳遞參數,其中 ?P 指的是是參數 (Parameter),variable 是參數名稱,pattern 是常規表示式,用來對應使用者 Request 的格式
✶ 上述 URL 格式 (?P<articleId>[0-9]+)/$ 的對應模式如下:
# 參數名稱為 articleId,其值為文章物件在資料庫裡的 id,此參數將透過 URL 傳給 views 程式
# [0-9]articleId 參數的資料型態是數字 (0 ~ 9)
# + 號:一個或多個 (亦即至少一個)
# [0-9]+:一或多個數字字元
# 例如:URL article/articleRead/120/, 則 views 函式 def articleRead(request, articleId) 在執行時, 其參數 articleId 的值即為 120
✶ 接著指定此 URL 對應的處理函式為 views.articleRead, 並將此 URL 對應命名為 articleRead
Model instance id

如何知道某筆資料在資料庫裡的 id 是什麼?

--> 進入管理者介面,點擊目標物件,會在 URL 欄位上看到以下網址:
http://localhost:8000/admin/<modelName>/<modelName>/<idNum>/change/

idNum 是一個數字,就是該筆資料的 id.

▸ 加入閱讀文章連結

article.html
...
    {% if forloop.first %}
      <h3><a href="{% url 'article:articleRead' item.id %}">{{ item.title }}</a></h3>
      <p>發表時間:{{ item.pubDateTime|date:'Y-m-d H:i' }}</p>
...
✶ 利用具名 URL 製作連結,並加上文章物件的 item.id 參數, Django 會將此格式轉為 article/articleRead/<id>/

▸ views 程式加入閱讀文章函式

article/views.py
from django.shortcuts import render, redirect, get_object_or_404
...

def articleCreate(request):
    ...


def articleRead(request, articleId):
    '''
    Read an article
        1. Get the "article" instance using "articleId"; redirect to the 404 page if not found
        2. Render the articleRead template with the article instance and its
           associated comments
    '''
    article = get_object_or_404(Article, id=articleId)
    context = {
        'article': article,
        'comments': Comment.objects.filter(article=article)
    }
    return render(request, 'article/articleRead.html', context)
✶ 匯入 get_object_or_404
✶ 函式有兩個參數:requestarticleId, 其中 articleId 是從 URL request 傳來
get_object_or_404():利用 articleIdArticle 資料表取出物件,如果找到,將物件指派給變數 article,否則結束 articleRead() 函式並在頁面顯示 404 (找不到) 頁面
✶ 設定 context:使用者要閱讀的文章及其所屬留言,留言資料利用 .filter() 來取出
✶ 最後顯示 articleRead.html 範本

▸ 閱讀文章頁面:

article/articleRead.html
<!doctype html>
{% load staticfiles %}
<html>
  <head>
    <title>部落格</title>
    <meta charset="utf-8">
    <link rel="stylesheet" href="{% static 'main/css/main.css' %}">
    <link rel="stylesheet" href="{% static 'article/css/article.css' %}">
  </head>
  <body>
    {% include 'main/menu.html' %}
    <h3>{{ article.title }}</h3>
    <p>發表時間:{{ article.pubDateTime|date:'Y-m-d H:i' }}</p>
    <div class="articleContent">{{ article.content|linebreaks }}</div>
    {% for comment in comments %}
      <div class="commentDiv">
        <span class="comment">{{ comment.content }}</span><br>
        <span class="commentTime">{{ comment.pubDateTime|date:'Y-m-d H:i'}}</span>
      </div>
    {% endfor %}
  </body>
</html>
✶ 顯示文章:包含標題、發表時間、及內容 (整篇顯示,加斷行)
✶ 以 for 迴圈顯示所屬留言內容及發表時間

▸ 測試:

✶ 在部落格頁面的文章標題呈現連結樣式,點擊之後可看到該文章及所屬留言
articleReadLink
✶ 嘗試在瀏覽器輸入錯誤的 URL 格式,或者不存在的物件,例如:
# article/articleRead/bestArticle/
--> 回到首頁,因為此沒有任何 URL mapping 可正確對應到此格式,因此由 blog/urls.py 裡的 url(r'^.*', include('main.urls')), 項目處理,亦即回到首頁
# article/articleRead/9999/
--> 回覆 404 錯誤訊息,因為找不到有此 id 的物件

(5) 修改文章

∗ 修改文章URL

▸ 規劃修改文章的 URL 的格式是 article/articleUpdate/<articleId>/

article/urls.py:
urlpatterns = [
    ...
    url(r'^articleRead/(?P<articleId>[0-9]+)/$', views.articleRead, name='articleRead'),
    url(r'^articleUpdate/(?P<articleId>[0-9]+)/$', views.articleUpdate, name='articleUpdate'),
]
✶ 同樣使用 articleId 參數傳遞文章物件的 id
✶ 處理函式:views.articleUpdate(),並將此 URL 對應命名為 articleUpdate

∗ views 程式

article/views.py

...

def articleRead(request):
    ...


def articleUpdate(request, articleId):
    '''
    Update the article instance:
        1. Get the article to update; redirect to 404 if not found
        2. Render a bound form if the method is GET
        3. If the form is valid, save it to the model, otherwise render a
           bound form with error messages
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')

▸ 暫時先顯示 article.html 頁面,其餘程式之後再補上

∗ 在閱讀文章頁面增加修改按鈕

article/articleRead.html:

...
<h3 class="inlineBlock">{{ article.title }}</h3>
<a class="btn inlineBlock" href="{% url 'article:articleUpdate' article.id %}">修改</a>
<p>發表時間:{{ article.pubDateTime|date:'Y-m-d H:i' }}</p>
...

<h3 class="inlineBlock">:之後會設定 CSS,讓文章標題與「修改」按鈕並列

▸ 「修改」按鈕:做成按鈕樣式並與標題並列

main/static/main/css/main.css:
...

.error {
  background-color: #fbd9d8;
  border: thin solid red;
}

/* Misc */
.inlineBlock {
  display: inline-block;
}
display:以「行內區塊」樣式顯示
--> 測試:可看到「修改」按鈕
修改按鈕

∗ 新增與修改功能共用同一範本

▸ 由於新增與修改的 HTML 格式完全相同,唯一不同的是新增時是空白表單,修改時則已有原始資料, 因此應該共用同一個範本,以免未來更動格式時需要修改兩個檔案 (DRY!)

▸ 將 articleCreate.html 更名為 articleCreateUpdate.html 並修改:

articleCreateUpdate.html
...
{% include 'main/menu.html' %}
{% if articleForm.instance.id %}
  <h2>修改文章</h2>
  <form method="post" action="{% url 'article:articleUpdate' articleForm.instance.id %}">
{% else %}
  <h2>新增文章</h2>
  <form method="post" action="{% url 'article:articleCreate' %}">
{% endif %}
  {% csrf_token %}
...
{% if articleForm.instance.id %}:判斷此表單是否綁定實例,若是,表示是修改功能, 否則就是新增功能
# 修改表單:<form method="post" action="{% url 'article:articleUpdate' articleForm.instance.id %}">
# 新增表單:<form method="post" action="{% url 'article:articleCreate' %}">

▸ 修改新增文章所使用的範本:

article/views.py:
def articleCreate(request):
    ...
    template = 'article/articleCreateUpdate.html'
    ...

∗ 建立 view 程式

article/views.py

...
def articleRead(request, articleId):
    ... 


def articleUpdate(request, articleId):
    '''
    Update the article instance:
        ...
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')  
    article = get_object_or_404(Article, id=articleId)
    template = 'article/articleCreateUpdate.html'
    if request.method == 'GET':
        articleForm = ArticleForm(instance=article)
        return render(request, template, {'articleForm':articleForm})
    # POST
    articleForm = ArticleForm(request.POST, instance=article)
    if not articleForm.is_valid():
        return render(request, template, {'articleForm':articleForm})
    articleForm.save()
    messages.success(request, '文章已修改') 
    return redirect('article:articleRead', articleId=articleId)

▸ 函式有兩個參數:requestarticleId, 其中 articleId 從 URL request 傳來

▸ 利用 get_object_or_404() 函式到資料庫查詢, 如果找到該筆物件就指派給變數 article,否則結束 articleUpdate() 函式並回覆 404 頁面

▸ 設定修改文章表單的範本為 articleCreateUpdate.html

if request.method == 'GET':請求的方法是 GET, 表示使用者點擊「修改」按鈕

ArticleForm(instance=article):產生一個 Django 表單並綁定從資料庫取出的物件
return render(...):呈現 HTML 表單,此時將從資料庫取出的資料綁定在表單中,讓使用者可以修改原始資料

▸ 如果 Request 方法是 POST:表示使用者已修改好資料,並送出表單

articleForm = ArticleForm(request.POST, instance=article): 產生一個 Django 表單而且綁定兩個項目:使用者的輸入以及所取出的文章物件
✶ 如果表單驗證失敗,重新顯示綁定資料的 HTML 表單,Django 會顯示驗證失敗的錯誤訊息
✶ 如果 Django 表單驗證成功,將表單資料儲存到 Model
✶ 設定成功訊息「文章已修改」
✶ 最後利用 redirect(...) 轉向到目的 URL,完成 Post/Redirect/Get 機制

∗ 在文章修改後顯示相關訊息

article/articleRead.html

...
{% include 'main/menu.html' %}
{% include 'main/messages.html' %}
<h3 class="inlineBlock">{{ article.title }}</h3>
...

▸ 測試:點「部落格」連結 --> 點某篇文章標題 --> 點「修改」按鈕 --> 修改標題或內容資料後送出

(6) 刪除文章

∗ 刪除文章功能

▸ 規劃 URL 的格式為 article/articleDelete/<articleId>/

article/urls.py
urlpatterns = [
    ...
    url(r'^articleUpdate/(?P<articleId>[0-9]+)/$', views.articleUpdate, name='articleUpdate'),
    url(r'^articleDelete/(?P<articleId>[0-9]+)/$', views.articleDelete, name='articleDelete'),
]

▸ 同樣使用參數 articleId 傳遞文章物件的 id

▸ 處理函式:views.articleDelete,並將此 URL 對應命名為 articleDelete

∗ views 程式

...

def articleUpdate(request, articleId):
    ...


def articleDelete(request, articleId):
    '''
    Delete the article instance:
        1. Render the article page if the method is GET
        2. Get the article to delete; redirect to 404 if not found
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')

▸ 暫時先顯示 article.html 頁面,其餘程式之後再補上

∗ 加入刪除文章按鈕

article.html:

...
{% for items in articleList %}
  {% for item in items %}
    {% if forloop.first %} 
      <h3 class="inlineBlock"><a href="{% url 'article:articleRead' item.id %}">{{ item.title }}</a></h3>
      <form class="inlineBlock" method="post" action="{% url 'article:articleDelete' item.id %}">
        {% csrf_token %}
        <input class="btn" type="submit" value="刪除">
      </form>
      <p>發表時間:{{ item.pubDateTime|date:'Y-m-d H:i' }}</p>
...

▸ 在文章標題連結之後加入表單,並將文章標題與刪除按鈕並列

articleDeleteButton

∗ 建立 view 程式來處理刪除文章之表單

▸ 在 article/views.py 加入 articleDelete() 函式

...

def articleUpdate(request, articleId):
    ...


def articleDelete(request, articleId):
    '''
    Delete the article instance:
        ...
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')
    if request.method == 'GET':
        return article(request)
    # POST
    articleToDelete = get_object_or_404(Article, id=articleId)
    articleToDelete.delete()
    messages.success(request, '文章已刪除')  
    return redirect('article:article')
✶ 函式有兩個參數:requestarticleId
if request.method == 'GET':如果是 GET request,表示使用者在惡意操弄系統 (這不是一般使用者,而是具備專業水準的使用者), 不點按鈕卻在網址欄直接輸入 GET 請求,應付方式:安靜地拒絕
--> 呼叫 article() 函式回到部落格頁面:網站安全是 Web 系統的超級重點, 處理 POST 請求時必須拒絕 GET 請求,藉由呼叫 article(...) 函式轉回部落格頁面, 系統並不會顯示任何錯誤訊息也不會當掉,但使用者知道吃了閉門羹,也會肯定開發者具有專業水準!
articleToDelete = ...:取出 idarticleId 的物件,找不到就回覆 404
articleToDelete.delete():將取出的文章物件刪除
✶ 最後設定訊息「文章已刪除」,並利用 redirect(...) 轉向到目的 URL 完成 Post/Redirect/Get 機制
--> 其實處理 GET Request 時呼叫 artcle(request) 與處理 POST Request 時呼叫 redirect(article:article) 都是轉向相同頁面,但前者是呼叫函式而非發出 Request, 效能好了那麼一滴滴!而後者則有 Post/Redirect/Get 機制,可避免重複送出表單

▸ 測試:

1. 先新增一篇測試文章,然後點擊該篇文章的「刪除」按鈕即可刪除文章
2. 在瀏覽器的 URL 欄位嘗試輸入 article/articleDelete/1/ 看看系統反應如何?
刪除資料
  1. 刪除一篇文章會連同其所屬留言一併刪除,這是 Foreign key 的標準刪除模式
  2. 目前機制為按下刪除鈕,該筆資料就直接刪除,但較安全的機制應該是在真正刪除前還需要請使用者再次確認, 需撰寫 JavaScript 程式:
    • 建立以下 jQuery 程式檔案,因為之後可以共用,因此放在 main app 中:
      main/static/main/js/deleteConfirm.js
      $(document).ready(function () {
        $(document).on('click', '.deleteConfirm', function() {
          return confirm("確定刪除?");
        });
      });
      
    • article.html 檔案下方連結 jQuery
      article/templates/article/article.html
      <body>
        ...
        
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
        <script src={% static 'main/js/deleteConfirm.js' %}></script>
      </body>
      </html>
      
    • 在刪除按鈕加上 CSS 類別:
      article/templates/article/article.html
                <form class="inlineBlock" method="post" ...>
                  {% csrf_token %}
                  <input class="btn deleteConfirm" type="submit" value="刪除">
                </form>
      

(7) 搜尋文章

∗ 使用者可輸入關鍵字來搜尋文章

▸ 規劃 URL 的格式為 article/articleSearch/

article/urls.py
urlpatterns = [
    ...
    url(r'^articleDelete/(?P<articleId>[0-9]+)/$', views.articleDelete, name='articleDelete'),
    url(r'^articleSearch/$', views.articleSearch, name='articleSearch'),
]
✶ 處理函式:views.articleSearch(),並將此 URL 對應命名為 articleSearch

∗ views 程式

...

def articleDelete(request, articleId):
    ...


def articleSearch(request):
    '''
    Search for articles:
        1. Get the "searchTerm" from the HTML page
        2. Use "searchTerm" for filtering
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')

▸ 暫時先顯示 article.html 頁面,其餘程式之後再補上

∗ 加入搜尋表單

article.html:

...
    {% include 'main/messages.html' %}
    <form class="inlineBlock" action="{% url 'article:articleSearch' %}">
      <input type="text" name="searchTerm">
      <input class="btn" type="submit" value="查詢">
    </form>
    <p class="inlineBlock"><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
    <br><br><hr>
    
    {% for items in itemList %}
    ...

▸ 搜尋表單:

✶ 利用 class="inlineBlock" 讓查詢欄位與按鈕和「新增文章」按鈕並列
✶ 未指定 method,因此預設為 GET request
✶ 搜尋欄位名稱為 searchTerm,文字資料型態

▸ 「新增文章」按鈕後加上空行與橫線

articleSearchButton

∗ 建立 view 程式來處理搜尋文章表單

article/views.py

from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.db.models.query_utils import Q

...

def articleDelete(request, articleId):
    ... 


def articleSearch(request):
    '''
    Search for articles:
        ...
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')
    searchTerm = request.GET.get('searchTerm')
    articles = Article.objects.filter(Q(title__icontains=searchTerm) |
                                      Q(content__icontains=searchTerm))
    context = {'articles':articles} 
    return render(request, 'article/articleSearch.html', context)

▸ 匯入 Q 模組,處理搜尋時的「或」條件組合

▸ 利用 request.GET.get() 函式從頁面擷取名為 searchTerm 的輸入欄位值,並指派給變數 searchTerm

request.GET 裡的資料是以 Python 字典結構儲存, 取出字典資料指令格式為 .get('<key>')
✶ 如果在 HTML 表單中找不到該變數,request.GET.get() 函式會回覆 None,因此也可以寫成 request.GET.get('<key>', None)

.filter():依照條件篩選資料,在文章標題及其內容都需要搜尋

✶ 查詢的「或」條件:使用 Q() 函式並以 | 符號串連
__icontains=searchTerm:Django ORM 以雙底線來執行條件比對的方法, 例如 icontains 為包含某些文字 (不分大小寫,Case insensitive),因此
# title__icontains=searchTerm:查詢標題是否包含搜尋字串
# content__icontains=searchTerm:查詢文章內容是否包含搜尋字串
✶ ORM 雙底線條件範例:
使用法 意義 範例
__contains=... 包含文字(區分大小寫) title__contains="關鍵字"
__icontains=... 包含文字(不分大小寫) title__icontains="關鍵字"
__startswith=... 以...開頭 title__startswith="開頭"
__endswith=... 以...結尾 title__endswith="結尾"
__gt=... 大於 year__gt=2000
__gte=... 大於等於 year__gte=2000
__lt=... 小於 year__lt=2000
__lte=... 小於等於 year__lte=2000
__in=... 在...串列中 year__in=[2012, 2014, 2016]
✶ 最後將查詢結果及搜尋字串傳至範本:搜尋字串再次出現,使用者才知道原先輸入的關鍵字為何

∗ 建立搜尋結果範本

article/templates/article/articleSearch.html:

<!doctype html>
{% load staticfiles %}
<html>
<head>
<title>部落格</title>
<meta charset="utf-8">
<link rel="stylesheet" href="{% static 'main/css/main.css' %}">
<link rel="stylesheet" href="{% static 'article/css/article.css' %}">
</head>
<body>
{% include 'main/menu.html' %}
<h1>歡迎來到我的部落格</h1>
<form class="inlineBlock" action="{% url 'article:articleSearch' %}">
  <input type="text" name="searchTerm">
  <input class="btn" type="submit" value="查詢">
</form>
<br><br><hr>
{% if not articles %}
  <p>查無資料</p>
{% else %}
  <table class="table table-striped table-hover">
    <tr><th>標題</th><th>發表時間</th></tr>
    {% for article in articles %}
    <tr>
      <td><a href="{% url 'article:articleRead' article.id %}">{{ article.title }}</a></td>
      <td>{{ article.pubDateTime|date:'Y-m-d H:i' }}</td>
    </tr>
    {% endfor %}
  </table>
{% endif %}
</body>
</html>

▸ 連結 article.css 設定搜尋結果資料的樣式

▸ 加入相同的搜尋表單,內含搜尋字串欄位及「查詢」鈕,搜尋欄位裡進行判斷:如果有搜尋字串就顯示

▸ 搜尋結果:

✶ 如果沒有資料,顯示「查無資料」訊息
✶ 如果有資料,將文章串列以表格顯示標題與發表時間
✶ 文章標題加上 <a href="{% url 'article:articleRead' article.id %}"> 連結, 使用者點擊連結,即可閱讀文章

▸ 表格加上樣式:

article/static/article/css/article.css:
...

input[type=text] {
  padding: 0.4em;
}

/* Table */
.table {
  margin: 0 auto;
  width: 90%;
  border-collapse: collapse;
}

.table th {
  font-size: 1.1em;
  padding: 10px 0 10px 10px;
  text-align: left;
  background-color: #468cb1;
  border-bottom: 1px solid black
}

.table td {
  color: #222222;
  background-color: #e0e0e0;
  padding: 12px 0 12px 10px;
}

.table-striped tr:nth-child(even) td {
  background-color: #E1F9DC;
}

.table-hover tr:hover td {
  background-color: #cdeaf0;
}
--> 測試:搜尋空字串,得到所有文章
articleSearchResult

∗ 顯示搜尋字串

▸ 目前搜尋後,搜尋字串欄位是空白,使用者看不出來搜尋字串為何

--> 顯示搜尋字串:在 views 中回覆搜尋字串之範本變數,在範本中顯示字串
article/views.py:
...

def articleSearch(request):
    ...
    context = {'articles':articles, 'searchTerm':searchTerm} 
    return render(request, 'article/articleSearch.html', context)
article/templates/article/articleSearch.html:

...
<form class="inlineBlock" action="{% url 'article:articleSearch' %}">
  <input type="text" name="searchTerm" {% if searchTerm %}value="{{ searchTerm }}"{% endif %}>
  <input class="btn" type="submit" value="查詢">
</form>
...
--> 測試:會顯示搜尋字串
articleSearchResult2
三振法則

相同的程式碼如果在不同地方發生兩次,還可接受,如果有三個地方都有相同 (或極類似) 的程式碼, 那就必須重構 (Refactor),讓程式看來更簡潔、效能更好

三振法則: 若有三個地方有相同程式碼就要重構 (Three strikes and you refactor),就像棒球,三振就要出局

▸ 目前在 article.htmlarticleSearch.html 都有相同的搜尋表單,雖然尚未三振,但如果覺得很 刺眼 的話,也可以進行重構 ;-)

✶ 建立 article/templates/article/searchForm.html:
<form class="inlineBlock" action="{% url 'article:articleSearch' %}">
  <input type="text" name="searchTerm" {% if searchTerm %}value="{{ searchTerm }}"{% endif %}>
  <input class="btn" type="submit" value="查詢">
</form>
article.html
  ...
  {% include 'main/messages.html' %}
  {% include 'article/searchForm.html' %}
  <p class="inlineBlock"><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
  
  ...
articleSearch.html
  ...
  <h1>歡迎來到我的部落格</h1>
  {% include 'article/searchForm.html' %}
  <br><br><hr>
  ...

(8) CRUDS 大功告成

▸ 本章的份量很重,主要牽涉到表單的增讀改刪查五大功能,這是表單的基本功能,也是 Web 系統最重要的目的:和使用者互動

▸ 讀者日後接觸較深入後可能也會發覺,一個 Web 系統的開發過程中,花在表單的功夫佔了很大的比例, 因此好好熟悉表單的操作是很重要的

▸ Django 表單尚有許多重要的進階功能,但本章限於篇幅,無法一一提及,教材的第二部份將有專章討論

再談命名

(8) 小結

∗ 上推專案到 Github

▸ Right click project --> Team --> Commit --> Commit message: Chapter 9 finished --> Commit and Push

∗ 練習:bookstore 專案

▸ 撰寫 CRUDS 四大功能:新增、閱讀、修改、及刪除書籍資料

▸ 撰寫搜尋書籍的功能:可指定許多條件來搜尋,例如:指定書名關鍵字、作者姓名關鍵字、出版商關鍵字, 出版日期範圍、售價範圍等,

▸ 加入一個簡單的猜數字遊戲:建立一個 guess app, 利用 HTML 表單讓使用者可以輸入數字,然後 views 程式處理使用者輸入的數字,程序如下:

  1. 在首頁設置一個「猜數字遊戲」按鈕,使用者點選後顯示輸入範圍為 0 ~ 9 數字的表單
  2. 使用者輸入資料,按下「送出」按鈕
  3. 在 views 程式中,設定一個常數數字,然後判斷使用者所輸入的數字,回覆使用者所猜的數字是大於、 小於、或是猜中常數數字,並允許使用者多次猜數字

* HTML 表單範例:使用者先看到空白表單,輸入資料送出後,表單下方出現猜測結果
guessEmpty --> guessResult

上一章       下一章