Django -- 從平凡到超凡

第 10 章    表單

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

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

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

▸ 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)

▸ 如果需要,可以客製化 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 的表單, 因為表單欄位來自 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)
ModelMultipleChoiceField:複選 (選項來自 Model)
URLField:網址
EmailField:電郵

▸ 常用 Widget 及其所對應的 HTML 元素:

widget=forms.TextInput():單行文字輸入框 <input type="text">
widget=forms.TextInput(attrs={'type':'date'}):單行日期輸入框 <input type="date">
widget=forms.PasswordInput():單行密碼輸入框 <input type="password">
widget=forms.NumberInput():單行數字輸入框 <input type="number">
widget=forms.Textarea():多行文字輸入框 <textarea>...</textarea>
widget=forms.Select():下拉式單選清單 <select>...</select>
widget=forms.RadioSelect():單選鈕 <input type="radio">
widget=forms.CheckboxSelectMultiple():複選框 <input type="checkbox">

(3) 新增文章

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

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

∗ 新增文章功能

▸ 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,
           * validate the form and display error messages if the form is invalid
           * else, save it to the model and redirect to the article page
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')
✶ 暫時先顯示 article.html 頁面,其餘程式之後再補上

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

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

▸ 頁面加入新增文章按鈕

article/templates/article/article.html
...
{% block content %}
<br>
<p><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
{% for article, comments in articles.items %}
...
✶ 點擊此 <a> 標籤將產生 GET 請求:要求系統顯示空白表單讓使用者填寫資料

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

main/static/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 表單

template = articleCreate.html:表單範本

if request.method == 'GET':HTTP request 的方法是 GET ,表示使用者點選 <a> 按鈕或在瀏覽器直接輸入 URL 方式發出請求,表示使用者準備要新增一篇文章 (註:因為此函式需處理 GET 與 POST 請求,故需判斷請求的型態,後續會再加上 POST 請求的處理程式)

{'articleForm':ArticleForm()}: 是利用 ArticleForm() 產生一個 Django 表單實例 (Instance)
✶ 以無參數方式呼叫 ArticleForm(),Django會產生一個空白表單實例,此種表單稱為「未綁定表單」 (Unbound form),亦即表單沒有綁定任何資料, 是個空白表單
return render(...):顯示 template 範本,內含一個未綁定表單

∗ 建立新增文章之範本

article/templates/article/articleCreate.html

{% extends 'main/base.html' %}
{% load staticfiles %}
{% block css %}
<link rel="stylesheet" href="{% static 'article/css/article.css' %}">
{% endblock %}
{% block heading %}新增文章{% endblock %}
{% block content %}
<form method="post" action="{% url 'article:articleCreate' %}">
  {% csrf_token %}
  {{ articleForm.as_p }}
  <input type="submit" value="送出">
</form>
{% endblock %}

▸ 加入 {% load staticfiles %} 範本標籤與 css 範本區塊,其內容為 CSS 檔案連結

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

{% csrf_token %}:加入表單安全機制,Django 要求所有發出 POST 請求的 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> 包住
<label for=...></label>:在表單呈現的欄位名稱, 文字內容取自 ArticleForm 類別裡的 label 資料
<input id=...>:輸入欄位,且自動產生下列資料
# id="id_<fieldName>":Django 自動產生 id, 其值就是 Model 欄位名稱冠上 id_ 前置字元
# max_length="128":其值取自 ArticleForm 類別裡的 max_length 資料
# name="<fieldName>":其值取自 Article model 裡的欄位名稱

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

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

article/views.py
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':
        print(ArticleForm())
        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)

▸ 首先刪除列印表單實例的指令

▸ 如果 HTTP 請求的方法是 POST:表示使用者已填好資料並按下「送出」按鈕以送出表單

articleForm = ArticleForm(request.POST)

request.POST 是使用者在表單裡所填的資料,透過 HTML 表單送到後端
✶ 以 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

∗ Post/Redirect/Get 設計模式

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

測試:新增一篇文章後,立刻按下鍵盤 F5 功能按鈕會彈出以下對話框 (此下為 Google 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 按鈕,只會刷新頁面,不會重送表單
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 請求
✶ 使用時機:傳送的資料會改變伺服器資料的狀態 (亦即資料會寫入資料庫)

▸ GET 與 POST 的差異:

✶ GET 在瀏覽器重新整理時是無害的,而 POST 會再次提交請求
✶ GET 產生的 URL 網址可以被 Bookmark,而 POST 不可以
✶ GET 請求會被瀏覽器主動cache,而 POST 不會,除非手動設置
✶ GET 請求只能進行 URL 編碼,而 POST 支援多種編碼方式
✶ GET 請求參數會被完整保留在瀏覽器歷史記錄裡,而 POST 中的參數不會被保留
✶ GET 請求在 URL 中傳送的參數是有長度限制的,而 POST 沒有
✶ 對參數的資料類型,GET 只接受 ASCII 字元,而 POST 沒有限制
✶ GET 參數透過 URL 傳遞,POST 則是放在 Request body 中

∗ 放棄編輯

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

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

▸ 測試「放棄」按鈕功能:會回到部落格頁面

∗ 訊息框架 (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(request, '...'), messages.info(request, '...'), messages.success(request, '...'), ...

▸ 在範本中的訊息是以串列儲存 (可以有許多訊息),因此需要使用 for 迴圈顯示,訊息的型態則儲存在 message.tags 裡。由於各個 App 都有顯示訊息的需求,因此在 base.html 中加入顯示訊息的範本指令,所有 App 均相同

main/templates/main/base.html
...
<h2>部落格 -- {% block heading %}{% endblock %}</h2>
{% for message in messages %}
  <p class="{{ message.tags }}">{{ message }}</p>
{% endfor %}
{% block content %}{% endblock %}
...

▸ 為不同訊息型態加上 CSS 樣式:

main/static/main/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;
}
✶ 成功訊息顯示淺綠色,錯誤訊息顯示淺紅色

▸ 測試:送出表單後會顯示訊息

articleCreateMessage

(4) 閱讀文章

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

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

▸ 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; 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 範本

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

article/urls.py
...
urlpatterns = [
    ...
    path('articleCreate/', views.articleCreate, name='articleCreate'),
    path('articleRead/<int:articleId>/', views.articleRead, name='articleRead'),
]
<int:articleId>:URL 參數的資料型態與名稱, Django 利用此方式在 URL 中傳遞參數,其中 int 指的是參數資料型態為整數, 後接冒號再接參數名稱 articleId
# 例如:URL article/articleRead/120/, 則 views 函式 def articleRead(request, articleId) 在執行時, 其參數 articleId 的值即為 120
✶ 接著指定此 URL 對應的處理函式為 views.articleRead, 並將此 URL 對應命名為 articleRead
URL 參數與 Model instance id

▸ URL 參數有以下資料型態:

✶ 整數:<int: ...>,例如:/2028/
✶ 不含 / 之字串:<str: ...>,例如:/bestArticle/
✶ 含 / 之字串:<path: ...>,例如:/the/best/article/
✶ ASCII 字元、數字、與短橫線:<slug: ...>,例如:/the-best-article/
✶ 唯一識別碼 (UUID):<uuid: ...>,例如:/075194d3-6885-417e-a8a8-6c931e272f00/

▸ 如何知道某筆資料在資料庫裡的 id (Model instance id) 是什麼?

✶ 進入管理者介面,點擊目標物件,URL 欄位會出現類似以下網址: http://localhost:8000/admin/<modelName>/<modelName>/<id>/change/
就可看到該筆資料的 id

▸ 加入閱讀文章連結

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

▸ 閱讀文章頁面:

article/templates/article/articleRead.html
{% extends 'main/base.html' %}
{% load staticfiles %}
{% block css %}
<link rel="stylesheet" href="{% static 'article/css/article.css' %}">
{% endblock %}
{% block heading %}閱讀文章{% endblock %}
{% block content %}
<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 %}
{% endblock %}
✶ 顯示文章:包含標題、發表時間、及內容 (整篇顯示,加斷行)
✶ 以 for 迴圈顯示所屬留言內容及發表時間

▸ 測試:

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

主要是後端程式的安全性,閱讀文章按鈕的產生方式是 <a href="{% url 'article:articleRead' article.id %}">,例如: <a href="/article/articleRead/20/">,其中 20 就是該文章在資料庫裡的 id,是由 views 程式傳到範本,因此使用者點擊此按鈕一定可以找到此篇文章。

然而,由於 <a> 標籤發出的是 GET 請求,也就是說,使用者也可以直接在瀏覽器的 URL 欄位輸入的 GET 請求,例如:localhost:8000/article/articleRead/2000/, 就可能找不到這筆資料了。使用者可以在前端做許多我們意想不到的事,因此,後端程式的安全性非常重要。

(5) 修改文章

∗ 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. If method is GET, render a bound form
        3. If method is POST,
           * validate the form and render a bound form if the form is invalid
           * else, save it to the model and redirect to the articleRead page
    '''
    # TODO: finish the code
    return render(request, 'article/article.html')

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

∗ 修改文章 URL

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

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

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

article/templates/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 並修改:

article/templates/article/articleCreateUpdate.html
...
{% block css %}
<link rel="stylesheet" href="{% static 'article/css/article.css' %}">
{% endblock %}
{% block heading %}
  {% if articleForm.instance.id %}
    修改文章
  {% else %}
    新增文章
  {% endif %}
{% endblock %}
{% block content %}
{% if articleForm.instance.id %}
  <form method="post" action="{% url 'article:articleUpdate' articleForm.instance.id %}">
{% else %}
  <form method="post" action="{% url 'article:articleCreate' %}">
{% endif %}
  {% csrf_token %}
  ...
{% endblock %}
{% 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 機制

▸ 測試:點「部落格」導航連結 點「簡單學習Django」標題 點「修改」按鈕 修改標題或內容資料後送出,可看到修改完成訊息

文章修改
再談為什麼修改文章要使用 get_object_or_404() 函式?

還是有關後端程式的安全性,送出修改文章的表單是 <form method="post" action="{% url 'article:articleUpdate' articleForm.instance.id %}">,例如:action="/article/articleUpdate/20/",由於 instance.id 是由 views 程式傳到範本,因此一定可以找到此篇文章。

然而,如前所述,使用者可以在前端做許多我們意想不到的事,例如,在修改文章頁面按下 F12 功能鍵進入開發者環境,使用者可以在 Elements 頁籤裡 *動態* 修改 HTML 程式碼,例如將上述的 20 (instance.id) 改為 2000,送出之後可能就會找不到該物件了。

(6) 刪除文章

▸ 建立刪除文章的功能

∗ views 程式

article/views.py

...

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
    '''
    if request.method == 'GET':
        return redirect('article:article')

    # POST
    article = get_object_or_404(Article, id=articleId)
    article.delete()
    messages.success(request, '文章已刪除')  
    return redirect('article:article')

▸ 函式有兩個參數:requestarticleIdarticleId 從 URL 傳進來

if request.method == 'GET':表示使用者在惡意操弄系統

✶ 會如此做的,不是一般使用者,而是具備專業水準的使用者,不點按鈕卻在網址欄直接輸入 GET 請求,應付方式:安靜地拒絕,亦即轉址到部落格頁面
✶ 如前所述,系統安全是超級重要的, 處理 POST 請求時必須拒絕 GET 請求,藉由轉址回到部落格頁面,系統並不會顯示任何錯誤訊息也不會當掉,但使用者知道吃了閉門羹, 也會肯定開發者具有專業水準!

▸ POST 請求:

article = ...:取出 idarticleId 的物件,找不到就回覆 404 頁面並結束函式執行
article.delete():將取出的文章物件刪除
✶ 最後設定「文章已刪除」訊息,並利用 redirect(...) 轉向到目的 URL 完成 Post/Redirect/Get 機制

∗ URL 格式

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

article/urls.py
urlpatterns = [
    ...
    path('articleUpdate/<int:articleId>/', views.articleUpdate, name='articleUpdate'),
    path('articleDelete/<int:articleId>/', views.articleDelete, name='articleDelete'),
]

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

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

∗ 加入刪除文章按鈕

article/templates/article/article.html

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

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

articleDeleteButton

▸ 測試:

1. 先新增一篇測試文章,然後點擊該篇文章的「刪除」按鈕即可刪除文章
2. 在瀏覽器的 URL 欄位嘗試輸入 localhost:8000/article/articleDelete/1/ 看看系統反應如何?

∗ 刪除資料再次確認

▸ 目前使用者按下刪除鈕,該筆資料就直接刪除,但較安全的機制應該是在真正刪除前還要請使用者再次確認, 此功能需要撰寫前端程式,在 main App 中新增以下 jQuery 程式檔案 (先新增 main/static/main/js 目錄):

main/static/main/js/deleteConfirm.js
$(document).ready(function () {
  $(document).on('click', '.deleteConfirm', function() {
    return confirm("確定刪除?");
  });
});

▸ 在 base.html 中建立 Google jQuery 連結,並且新增一個 script 範本區塊,以方便各個 App 置換所需的程式

main/templates/main/base.html
...
{% block content %}{% endblock %}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/x.x.x/jquery.min.js"></script>
{% block script %}{% endblock %}
</body>
</html>
# 其中 x.x.x 是 jQuery 版本數字, 版本資訊可在 Google jQuery 3.x snippet 查詢

▸ 在 article.html 裡的刪除按鈕加上 deleteConfirm 之 CSS 類別,並在最後加上 script 範本區塊,內容為連結到 main app 的 deleteConfirm.js

article/templates/article/article.html
...
{% block content %}
...

<form class="inlineBlock" method="post" ...>
  {% csrf_token %}
  <input class="btn deleteConfirm" type="submit" value="刪除">
</form>

...
{% endblock %}
{% block script %}
<script src="{% static 'main/js/deleteConfirm.js' %}"></script>
{% endblock %}
重整頁面後,按下「刪除」按鈕,就會出現確認對話框

(7) 搜尋文章

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

∗ 在 views 中新增 articleSearch() 函式來搜尋文章

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:
        1. Get the "searchTerm" from the HTML form
        2. Use "searchTerm" for filtering
    '''
    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 模組,處理搜尋時的「或」條件組合

▸ 規劃關鍵字為 searchTerm,並利用 request.GET.get() 函式從 HTML 表單取出名為 searchTerm 的輸入欄位值,並指派給變數 searchTerm

request.GET 裡的資料是以 Python 字典結構儲存,因此利用 .get() 方法取出資料
✶ 如果在 HTML 表單中找不到該變數,request.GET.get() 函式會回覆 None

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

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

∗ URL 格式

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

article/urls.py
urlpatterns = [
    ...
    path('articleDelete/<int:articleId>/', views.articleDelete, name='articleDelete'),
    path('articleSearch/', views.articleSearch, name='articleSearch'),
]
✶ 處理函式:views.articleSearch(),並將此 URL 對應命名為 articleSearch

∗ 搜尋表單及程序

▸ 規劃:在文章列表上方加入搜尋表單,使用者輸入關鍵字後,轉到搜尋結果頁面,上方依舊有搜尋表單 (可以再次搜尋),下方顯示搜尋結果

▸ 既然在兩個頁面都有相同表單,因此獨立出一個搜尋檔案:

article/templates/article/searchForm.html
<form class="inlineBlock" action="{% url 'article:articleSearch' %}">
  <input type="text" name="searchTerm">
  <input class="btn" type="submit" value="查詢">
</form>
✶ 設定 class="inlineBlock" CSS 類別,讓查詢按鈕和「新增文章」按鈕可以並列
✶ 表單預設為 GET 請求,因此未指定 method
action 使用具名 URL 格式
✶ 搜尋欄位為文字資料型態,變數名稱為 searchTerm

▸ 在文章列表上方加上查詢表單:

article/templates/article/article.html
...
{% block content %}
{% include 'article/searchForm.html' %}
<p class="inlineBlock"><a class="btn" href="{% url 'article:articleCreate' %}">新增文章</a></p>
<br><br><hr>

{% for article, comments in articles.items %}
...
✶ 在「新增文章」按鈕加上 class="inlineBlock" CSS 類別,以便與查詢按鈕並列,其後再加上兩空行與一橫線以隔開搜尋區與資料區

▸ 結果:

articleSearchButton

∗ 建立查詢結果範本

article/templates/article/articleSearch.html

{% extends 'main/base.html' %}
{% load staticfiles %}
{% block heading %}查詢結果{% endblock %}
{% block css %}
<link rel="stylesheet" href="{% static 'article/css/article.css' %}">
{% endblock %}
{% block content %}
{% include 'article/searchForm.html' %}
<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 %}
{% endblock %}

▸ 連結 article.css,稍後再設定搜尋結果的表格樣式

▸ 匯入搜尋表單

▸ 搜尋結果 (articles):

✶ 如果沒有資料,顯示「查無資料」訊息
✶ 如果有資料,將文章串列以表格顯示標題與發表時間
✶ 文章標題加上 <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/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>

▸ 測試:會顯示搜尋字串

articleSearchResult2

(8) 增讀改刪查大功告成

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

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

再談命名

∗ 上推專案到 Github

... Commit message: Chapter 10 finished Commit and Push

▸ 練習