Django初學者入門指南3-高級概念(譯&改)

Django初學者入門指南1-初識(譯&改)

Django初學者入門指南2-基礎知識(譯&改)

Django初學者入門指南3-高級概念(譯&改)

Django初學者入門指南4-安全認證(譯&改)

Django初學者入門指南5-存儲數(shù)據(jù)(譯&改)

Django初學者入門指南6-基于類的頁面(譯&改)

Django初學者入門指南7-部署發(fā)布(譯&改)--施工中

>>原文地址 By Vitor Freitas

簡介

在本教程中椭微,我們將深入研究兩個基本概念:URLsForm眨八。在這個過程中痪寻,我們將探討許多其他概念焕阿,如創(chuàng)建可重用模板和安裝第三方庫怔球。我們還將編寫大量的單元測試。

如果您從第一部分開始就遵循本教程系列,編寫項目代碼并逐步遵循教程荸百,則可能需要在開始前更新models.py

boards/models.py

class Topic(models.Model):
    # other fields...
    # Add `auto_now_add=True` to the `last_updated` field
    last_updated = models.DateTimeField(auto_now_add=True)

class Post(models.Model):
    # other fields...
    # Add `null=True` to the `updated_by` field
    updated_by = models.ForeignKey(User, null=True, related_name='+')

更新好后,在虛擬環(huán)境中執(zhí)行下面的命令進行遷移更新:

python manage.py makemigrations
python manage.py migrate

如果已經給updated_by屬性配置了null=True滨攻,last_updated屬性配置了auto_now_add=True够话,那么你就可以不用進行上面的修改蓝翰。

如果需要直接使用源代碼的話,可以通過GitHub直接獲取女嘲。當前狀態(tài)的項目代碼可以在發(fā)布的標簽v0.2-lw下找到畜份,也可以直接點擊下面的鏈接前往獲取:

https://github.com/sibtc/django-beginners-guide/tree/v0.2-lw

接下來我們繼續(xù)開發(fā)吧欣尼。


URLs

繼續(xù)開發(fā)我們的應用程序爆雹,現(xiàn)在必須實現(xiàn)一個新的頁面來列出屬于某個給定版塊Board的所有主題Topic。簡單回顧一下媒至,下面可以看到我們在上一個教程中繪制的線框圖:

圖1:版塊詳情線框圖顶别,列出Django版塊中的所有主題

我們先來編輯myproject目錄下的urls.py文件:

<details>
<summary>原始版本</summary>
原始版本的myproject/urls.py

from django.conf.urls import url
from django.contrib import admin

from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    url(r'^admin/', admin.site.urls),
]

</details>

修訂版本的myproject/urls.py

from django.urls import re_path
from django.contrib import admin

from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    re_path(r'^admin/', admin.site.urls),
]

這次讓我們花點時間分析一下urlpatternsurl

URL調度器和URLconf(URL配置)是Django應用程序的基本部分拒啰。一開始驯绎,它可能看起來很混亂;還記得當我第一次開始使用Django開發(fā)時谋旦,經歷了一段艱難的時期剩失。

事實上,現(xiàn)在Django開發(fā)人員正在研究一個簡化路由語法的方案. 但現(xiàn)在在版本1.11里册着,讓我們試著理解它是如何工作的吧拴孤。

一個項目可以有很多urls.py在應用程序中分發(fā),但是Django需要一個urls.py作為出發(fā)點甲捏。這個特別的urls.py稱為root URLconf演熟,它被定義在settings.py文件。

myproject/settings.py

ROOT_URLCONF = 'myproject.urls'

它在創(chuàng)建項目時就自動生成好了司顿,這里就不再需要去修改了芒粹。

當Django收到請求時,它就會在項目的URLconf中搜索匹配項大溜。它從urlpatterns變量的第一條開始化漆,逐條嘗試與請求的url進行匹配。

如果Django找到匹配項钦奋,它將通過re_path(url也相同)方法的第二個參數(shù)把請求傳遞給view function座云。urlpatterns中的順序很重要,因為Django一旦找到匹配項就會停止搜索付材。如果Django在URLconf中找不到匹配項朦拖,它將引發(fā)一個404異常,就是Page Not Found的錯誤代碼厌衔。

以下是對urlre_path函數(shù)的剖析:

def url(regex, view, kwargs=None, name=None):
    # ...

# re_path的用法與url完全相同贞谓,Django 2.x版本開始,不建議使用url方法葵诈,所以使用re_path方法
  • regex: 這個就是用于匹配請求url的正則表達式裸弦,需要注意的是它不會匹配到url中的請求參數(shù),比如http://127.0.0.1:8000/boards/?page=2作喘,正則表達式只會嘗試匹配/boards/部分理疙,其他則忽略掉了。
  • view: 指定用于響應請求url的頁面函數(shù)泞坦,它同樣也可以支持通過include函數(shù)引入其他子文件目錄的urls.py文件窖贤。例如,可以使用它來定義一組特定于應用程序的url贰锁,并使用前綴將其包含在根URLconf中赃梧。稍后我們將對這個概念進行更多的探討。
  • kwargs: 傳遞到目標頁面的任意參數(shù)豌熄,它通常用于對可重用視圖進行一些簡單的自定義授嘀,實際場景中不經常用它。
  • name: 給定URLs的唯一標識符锣险,這是一個非常重要的特性蹄皱,一定要記住給你的網址命名。通過這種方式芯肤,您就可以通過更改regex來更改整個項目中的特定URLs巷折。因此,不要在視圖或模板中硬編碼URLs崖咨,并且始終使用URLs的名稱來引用URLs锻拘,這一點很重要。
匹配URLs的正則表達式
基礎URLs正則表達式

url的創(chuàng)建非常簡單击蹲,這只是一個字符串匹配的問題署拟。假設我們想要創(chuàng)建一個about頁面,可以這樣定義:

myproject/urls.py

<details>
<summary>原始版本</summary>

from django.conf.urls import url
from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^about/$', views.about, name='about'),
]

</details>

<details open>
<summary>修訂版本</summary>

from django.urls import re_path
from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^about/$', views.about, name='about'),
]

</details>

我們也可以創(chuàng)建更深層級的URL:

<details>
<summary>原始版本</summary>

from django.conf.urls import url
from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^about/$', views.about, name='about'),
    url(r'^about/company/$', views.about_company, name='about_company'),
    url(r'^about/author/$', views.about_author, name='about_author'),
    url(r'^about/author/vitor/$', views.about_vitor, name='about_vitor'),
    url(r'^about/author/erica/$', views.about_erica, name='about_erica'),
    url(r'^privacy/$', views.privacy_policy, name='privacy_policy'),
]

</details>

<details open>
<summary>修訂版本</summary>

from django.urls import re_path
from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^about/$', views.about, name='about'),
    re_path(r'^about/company/$', views.about_company, name='about_company'),
    re_path(r'^about/author/$', views.about_author, name='about_author'),
    re_path(r'^about/author/vitor/$', views.about_vitor, name='about_vitor'),
    re_path(r'^about/author/erica/$', views.about_erica, name='about_erica'),
    re_path(r'^privacy/$', views.privacy_policy, name='privacy_policy'),
]

</details>

以上都是一些URL路由的例子际邻,針對這個路由芯丧,還需要在頁面的函數(shù)中定義下面的函數(shù):

def about(request):
    # do something...
    return render(request, 'about.html')

def about_company(request):
    # do something else...
    # return some data along with the view...
    return render(request, 'about_company.html', {'company_name': 'Simple Complex'})
URLs路由的進階用法

URL路由的進階用法是通過利用regex匹配特定類型的數(shù)據(jù)并創(chuàng)建動態(tài)URL來實現(xiàn)的。

例如創(chuàng)建一個用戶個人資料頁面世曾,例如github.com/vitorfs或者twitter.com/vitorfs缨恒,其中vitorfs是我的用戶名,可以通過下面的方式實現(xiàn):

<details>
<summary>原始版本</summary>

from django.conf.urls import url
from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^(?P<username>[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]

</details>

<details open>
<summary>修訂版本</summary>

from django.urls import re_path
from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^(?P<username>[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]

</details>

通過這種方式轮听,將匹配到Django用戶模型的所有有效用戶的姓名骗露。

或許你注意到了,上面的URL正則匹配的范圍非常大血巍,因為它定義在了根url而不是類似于/profile/<username>這樣的url萧锉。在這種情況下,我們定義的/about/的匹配就需要提到它的前面述寡,就像下面這樣:

<details>
<summary>原始版本</summary>

from django.conf.urls import url
from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^about/$', views.about, name='about'),
    url(r'^(?P<username>[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]

</details>
<details open>
<summary>修訂版本</summary>

from django.urls import re_path
from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^about/$', views.about, name='about'),
    re_path(r'^(?P<username>[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]

</details>

如果about頁面是在username URL模式之后定義柿隙,Django將永遠找不到它叶洞,因為單詞about將匹配username的正則表達式,并且由頁面user_profile響應而不是about頁面函數(shù)禀崖。

但是這樣定義還是有一些副作用衩辟。例如從現(xiàn)在起,必須禁止使用about作為用戶名波附,因為如果選擇about作為其用戶名艺晴,此人將永遠看不到他的個人資料頁面。

URL路由的那些事

旁注: 如果你想為個人資料頁面定義更加合理的URL路由掸屡,建議使用/u/vitorfs/或者是/@vitorfs/這樣帶有前綴標識符的方式封寞。

不過如果你依然想使用前面的方式定義URL路由,那么需要用到一個用戶名的禁用文字列表:github.com/shouldbee/reserved-usernames仅财”肪浚或者是這個我自學Django時,我自己創(chuàng)建的禁用文字列表:github.com/vitorfs/parsifal/满着。

這樣的沖突是非常容易發(fā)生的谦炒,拿GitHub來說:他們有一個URL路由來展示你當前關注的項目或用戶:github.com/watching。如果有人用watching作為用戶名风喇,那么他就沒有辦法訪問到他自己的個人資料頁面宁改。同樣的,通過github.com/watching/repositories我們本該看到我們關注的項目魂莫,但是卻可能訪問到這個用戶的項目列表还蹲,類似我的項目列表:github.com/vitorfs/repositories

這種URL路由的整體思想是創(chuàng)建動態(tài)頁面耙考,其中URL的一部分將用作某個資源的標識符谜喊,該資源將用于組成頁面。例如倦始,該標識符可以是整數(shù)ID或字符串斗遏。

首先,我們將使用Board的ID為Topic創(chuàng)建一個動態(tài)頁面鞋邑,讓我們再看看在URL部分開頭給出的示例:

<details>
<summary>原始版本</summary>

url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics')

</details>

<details open>
<summary>修訂版本</summary>

re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics')

</details>

正則表達式\d+匹配的是至少一位數(shù)字的整數(shù)诵次。我們會通過這個數(shù)字去數(shù)據(jù)庫中查詢Board類型的實例。再看下正則表達式的寫法(?P<pk>\d+)枚碗,這種寫法會讓Django將這個正則表達式匹配到的字符串賦值給變量pk逾一。

所以對應的在views.py中實現(xiàn)方法:

def board_topics(request, pk):
    # do something...

因為我們使用了(?P<pk>\d+)這樣的正則表達式,所以在board_topics接收的變量就必須是pk肮雨。

如果不需要指定該參數(shù)的名稱遵堵,那么可以使用下面的寫法:

<details>
<summary>原始版本</summary>

url(r'^boards/(\d+)/$', views.board_topics, name='board_topics')

</details>

<details open>
<summary>修訂版本</summary>

re_path(r'^boards/(\d+)/$', views.board_topics, name='board_topics')

</details>

這樣我們就可以定義成:

def board_topics(request, board_id):
    # do something...

或者是:

def board_topics(request, id):
    # do something...

定義成什么樣的名字不重要,使用命名參數(shù)可以讓我們更清楚url中匹配的各個參數(shù),在使用更多變量更大url時陌宿,更加容易理解锡足。

旁注: PK 和 ID 的區(qū)別?
PK 就是Primary Key壳坪,它是訪問模型主鍵的快捷方式舱污,所有Django模型都有這個屬性。
在大多數(shù)情況下弥虐,使用pk屬性與id相同。這是因為如果我們不為模型定義主鍵媚赖,Django將自動創(chuàng)建一個名為idAutoField并默認為主鍵霜瘪。但如果你為一個模型定義了一個不同的主鍵,假設字段email是自定義的主鍵惧磺。這時就可以使用obj.email或者obj.pk去訪問它了颖对。

使用URLs API

是時候寫些代碼了。讓我們來實現(xiàn)版塊的主題列表頁面吧磨隘。

版塊內容缤底,顯示該版塊下的所有主題

首先在urls.py中添加一個新的url路由:

<details>
<summary>原始版本</summary>

myproject/urls.py

from django.conf.urls import url
from django.contrib import admin

from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    url(r'^admin/', admin.site.urls),
]

</details>

<details open>
<summary>修訂版本</summary>

myproject/urls.py

from django.urls import re_path
from django.contrib import admin

from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    re_path(r'^admin/', admin.site.urls),
]

</details>

然后再實現(xiàn)頁面方法board_topics

boards/views.py

from django.shortcuts import render
from .models import Board

def home(request):
    # code suppressed for brevity

def board_topics(request, pk):
    board = Board.objects.get(pk=pk)
    return render(request, 'topics.html', {'board': board})

templates目錄下,新建一個topics.html:

templates/topics.html

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{{ board.name }}</title>
    <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
  </head>
  <body>
    <div class="container">
      <ol class="breadcrumb my-4">
        <li class="breadcrumb-item">Boards</li>
        <li class="breadcrumb-item active">{{ board.name }}</li>
      </ol>
    </div>
  </body>
</html>

提示: 現(xiàn)在我們先臨時創(chuàng)建固定的html文件番捂,在后續(xù)的教程中會教大家使用可復用的html模板.

現(xiàn)在我們在瀏覽器中打開http://127.0.0.1:8000/boards/1/个唧,應該會看到如下的頁面:

主題列表

讓我們寫一點測試用例吧,打開tests.py并添加如下內容:

boards/tests.py

# from django.core.urlresolvers import reverse # 注意現(xiàn)在新版本放到了下面
from django.urls import resolve, reverse
from django.test import TestCase
from .views import home, board_topics
from .models import Board

class HomeTests(TestCase):
    # ...

class BoardTopicsTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')

    def test_board_topics_view_success_status_code(self):
        url = reverse('board_topics', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 200)

    def test_board_topics_view_not_found_status_code(self):
        url = reverse('board_topics', kwargs={'pk': 99})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 404)

    def test_board_topics_url_resolves_board_topics_view(self):
        view = resolve('/boards/1/')
        self.assertEquals(view.func, board_topics)

需要注意一下我們使用了setUp方法设预,在這個方法里徙歼,我們創(chuàng)建了一個Board實例來執(zhí)行測試用例。因為Django測試工具不會對當前數(shù)據(jù)庫進行測試鳖枕。為了運行測試魄梯,Django會動態(tài)創(chuàng)建一個新數(shù)據(jù)庫,應用所有模型遷移宾符,運行測試酿秸,完成后銷毀測試數(shù)據(jù)庫。

所以我們需要在setUp方法中準備用于測試的數(shù)據(jù)魏烫,以便模擬測試場景辣苏。

  • test_board_topics_view_success_status_code:用于檢測是否能為當前已有的Board對象數(shù)據(jù)返回正確的狀態(tài)碼(200)。
  • test_board_topics_view_not_found_status_code:用于檢測是否能為當前沒有的Board對象數(shù)據(jù)返回找不到數(shù)據(jù)的狀態(tài)碼(404)则奥。
  • test_board_topics_url_resolves_board_topics_view:用于檢測Django是否用正確的頁面響應方法來響應指定的URL考润。

讓我們運行測試用例吧:

python manage.py test

可以看到下面的輸出:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.E...
======================================================================
ERROR: test_board_topics_view_not_found_status_code (boards.tests.BoardTopicsTests)
----------------------------------------------------------------------
Traceback (most recent call last):
# ...
boards.models.DoesNotExist: Board matching query does not exist.

----------------------------------------------------------------------
Ran 5 tests in 0.093s

FAILED (errors=1)
Destroying test database for alias 'default'...

測試用例test_board_topics_view_not_found_status_code沒有通過,拋出了異常boards.models.DoesNotExist: Board matching query does not exist.

拋出異常的500錯誤頁面

在配置為DEBUG=False的生產環(huán)境中读处,訪問者將看到一個500 Internal Server Error頁面糊治。但這不是我們想要的結果。

我們需要的是404 Page Not Found這樣的頁面罚舱,所以讓我們稍微改下代碼:

boards/views.py

from django.shortcuts import render
from django.http import Http404
from .models import Board

def home(request):
    # code suppressed for brevity

def board_topics(request, pk):
    try:
        board = Board.objects.get(pk=pk)
    except Board.DoesNotExist:
        raise Http404
    return render(request, 'topics.html', {'board': board})

讓我們再試一次:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.042s

OK
Destroying test database for alias 'default'...

好了井辜!這就是我們想要的結果绎谦。

知名頁面:404

這是Django配置為DEBUG=False時的默認404頁面。稍后我們可以自行定制這個404頁面粥脚。

這是一個非常常用的測試用例窃肠,實際上Django有一個現(xiàn)成的方法來返回404頁面。

讓我們重寫board_topics

from django.shortcuts import render, get_object_or_404
from .models import Board

def home(request):
    # code suppressed for brevity

def board_topics(request, pk):
    board = get_object_or_404(Board, pk=pk)
    return render(request, 'topics.html', {'board': board})

好了嗎刷允?我們再來一次冤留。

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.052s

OK
Destroying test database for alias 'default'...

妥了,我們繼續(xù)后面的開發(fā)吧树灶。

下一步是在屏幕中創(chuàng)建導航鏈接纤怒。主頁應該有一個鏈接,將訪問者鏈接到一個給定的Board的主題頁天通。類似地泊窘,主題頁面也應該有一個指向主頁的鏈接。

我們先來為主頁HomeTests編寫一些測試用例:

boards/tests.py

class HomeTests(TestCase):
    def setUp(self):
        self.board = Board.objects.create(name='Django', description='Django board.')
        url = reverse('home')
        self.response = self.client.get(url)

    def test_home_view_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_home_url_resolves_home_view(self):
        view = resolve('/')
        self.assertEquals(view.func, home)

    def test_home_view_contains_link_to_topics_page(self):
        board_topics_url = reverse('board_topics', kwargs={'pk': self.board.pk})
        self.assertContains(self.response, 'href="{0}"'.format(board_topics_url))

注意現(xiàn)在也為HomeTests添加了一個setUp方法像寒,這是因為現(xiàn)在我們需要一個Board實例烘豹,同時還將urlresponse移動到setUp,這樣就可以在新的測試中重用相同的參數(shù)了诺祸。

這里的新測試是test_home_view_contains_link_topics_page,使用assertContents方法來測試響應體是否包含給定的文本携悯。在測試中檢測的文本是a標簽的href部分。這就等同于測試響應體是否包含文本href="/boards/1/"序臂。

讓我們運行測試:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....F.
======================================================================
FAIL: test_home_view_contains_link_to_topics_page (boards.tests.HomeTests)
----------------------------------------------------------------------
# ...

AssertionError: False is not true : Couldn't find 'href="/boards/1/"' in response

----------------------------------------------------------------------
Ran 6 tests in 0.034s

FAILED (failures=1)
Destroying test database for alias 'default'...

讓我們繼續(xù)修改代碼來通過這個單元測試蚌卤。

修改home.html模板:

templates/home.html

<!-- code suppressed for brevity -->
<tbody>
  {% for board in boards %}
    <tr>
      <td>
        <a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a>
        <small class="text-muted d-block">{{ board.description }}</small>
      </td>
      <td class="align-middle">0</td>
      <td class="align-middle">0</td>
      <td></td>
    </tr>
  {% endfor %}
</tbody>
<!-- code suppressed for brevity -->

這里主要是將以前的:

{{ board.name }}

修改為:

<a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a>

始終使用{% url %}模板標記來組合應用程序的url。第一個參數(shù)是URL的name(在URLconf中定義奥秆,即urls.py)逊彭,則可以根據(jù)需要傳遞任意數(shù)量的參數(shù)。

如果它是一個像主頁那樣的簡單URL构订,那么它就是{% URL 'home' %}侮叮。

保存文件并再次運行測試:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.037s

OK
Destroying test database for alias 'default'...

好了,我們可以在瀏覽器里查看了:

現(xiàn)在我們來寫回到首頁的代碼悼瘾,先寫測試用例:

boards/tests.py

class BoardTopicsTests(TestCase):
    # code suppressed for brevity...

    def test_board_topics_view_contains_link_back_to_homepage(self):
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        response = self.client.get(board_topics_url)
        homepage_url = reverse('home')
        self.assertContains(response, 'href="{0}"'.format(homepage_url))

運行測試:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.F.....
======================================================================
FAIL: test_board_topics_view_contains_link_back_to_homepage (boards.tests.BoardTopicsTests)
----------------------------------------------------------------------
Traceback (most recent call last):
# ...

AssertionError: False is not true : Couldn't find 'href="/"' in response

----------------------------------------------------------------------
Ran 7 tests in 0.054s

FAILED (failures=1)
Destroying test database for alias 'default'...

更新模板html:

templates/topics.html

{% load static %}<!DOCTYPE html>
<html>
  <head><!-- code suppressed for brevity --></head>
  <body>
    <div class="container">
      <ol class="breadcrumb my-4">
        <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
        <li class="breadcrumb-item active">{{ board.name }}</li>
      </ol>
    </div>
  </body>
</html>

運行測試:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.061s

OK
Destroying test database for alias 'default'...

正如我前面提到的囊榜,URL路由是web應用程序的一個基礎部分。有了這些知識亥宿,我們就可以繼續(xù)開發(fā)了走净。接下來拇舀,我會給出一些常用URL Patterns献烦,以便可以更好的掌握這個知識牌借。

常用URL Patterns

比較深一點的技巧是regex,所以我準備了一個最常用的URL Patterns的列表。當需要一個特定的URL時悟狱,你可以隨時引用這個列表静浴。

Primary Key AutoField Value
Regex (?P<pk>\d+)
Example url(r'^questions/(?P<pk>\d+)/$', views.question, name='question')
Valid URL /questions/934/
Captures {'pk': '934'}
Slug Field Value
Regex (?P<slug>[-\w]+)
Example url(r'^posts/(?P<slug>[-\w]+)/$', views.post, name='post')
Valid URL /posts/hello-world/
Captures {'slug': 'hello-world'}
Slug Field with Primary Key Value
Regex (?P<slug>[-\w]+)-(?P<pk>\d+)
Example url(r'^blog/(?P<slug>[-\w]+)-(?P<pk>\d+)/$', views.blog_post, name='blog_post')
Valid URL /blog/hello-world-159/
Captures {'slug': 'hello-world', 'pk': '159'}
Django User Username Value
Regex (?P<username>[\w.@+-]+)
Example url(r'^profile/(?P<username>[\w.@+-]+)/$', views.user_profile, name='user_profile')
Valid URL /profile/vitorfs/
Captures {'username': 'vitorfs'}
Year Value
Regex (?P<year>[0-9]{4})
Example url(r'^articles/(?P<year>[0-9]{4})/$', views.year_archive, name='year')
Valid URL /articles/2016/
Captures {'year': '2016'}
Year / Month Value
Regex (?P<year>[0-9]{4})/(?P<month>[0-9]{2})
Example url(r'^articles/(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$', views.month_archive, name='month')
Valid URL /articles/2016/01/
Captures {'year': '2016', 'month': '01'}

如果需要查看更多其他例子,可以前往這里查看:常用URL Patterns.


可復用的模板

到目前為止挤渐,我們常常在復制和粘貼相同的內容到HTML文檔苹享,從長遠來看這是不可持續(xù)的。這也是一種不好的做法浴麻。

在本節(jié)中得问,我們將重構HTML模板,抽出可復用的部分软免,創(chuàng)建一個master page椭赋,并且只在各自模板寫它獨有的代碼。

templates文件夾中創(chuàng)建一個名為base.html的文件:

templates/base.html

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Django Boards{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
  </head>
  <body>
    <div class="container">
      <ol class="breadcrumb my-4">
        {% block breadcrumb %}
        {% endblock %}
      </ol>
      {% block content %}
      {% endblock %}
    </div>
  </body>
</html>

這將是我們的基礎頁面或杠,往后創(chuàng)建的每個模板,都會extends這個特殊的模板宣蔚。注意現(xiàn)在我們引入了{% block %}標記向抢,它將在模板中預留一個位置,子模板(擴展該模板頁面的其他頁面)可以在該位置中插入代碼和HTML胚委。

{% block title %}的這個位置挟鸠,我們還設置了一個默認值,即Django Boards亩冬。如果我們沒有在子模板中為{% block title %}設置值艘希,則會使用該默認值。

現(xiàn)在讓我們重構兩個模板:home.html以及topics.html.

templates/home.html

{% extends 'base.html' %}

{% block breadcrumb %}
  <li class="breadcrumb-item active">Boards</li>
{% endblock %}

{% block content %}
  <table class="table">
    <thead class="thead-inverse">
      <tr>
        <th>Board</th>
        <th>Posts</th>
        <th>Topics</th>
        <th>Last Post</th>
      </tr>
    </thead>
    <tbody>
      {% for board in boards %}
        <tr>
          <td>
            <a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a>
            <small class="text-muted d-block">{{ board.description }}</small>
          </td>
          <td class="align-middle">0</td>
          <td class="align-middle">0</td>
          <td></td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

注意現(xiàn)在home.html的第一行是{% extends 'base.html' %}硅急,Django會通過這個聲明去找到并加載base.html作為母模板覆享。 然后我們再往blocks位置中填充頁面特有的樣式和布局。

templates/topics.html

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
    <!-- 這里先留空营袜,我們后面會來完善 -->
{% endblock %}

topics.html文件中撒顿,我們修改了{% block title %}的值。注意這里使用{{ block.super }}來獲取到了母模板中的值荚板。這里就將頁面的標題base.html定義為了Django Boards凤壁。同樣Python版塊的頁面,標題就會變?yōu)?code>Python - Django Boards跪另,而Random版塊的標題就會變?yōu)?code>Random - Django Boards拧抖。

我們來試試運行測試用例,看看會不會有什么錯誤免绿。

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.067s

OK
Destroying test database for alias 'default'...

完美唧席!所有功能正常。

使用現(xiàn)在的base.html作為母模板,我們可以很輕松地添加一個帶菜單的頂部條:

templates/base.html

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Django Boards{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
  </head>
  <body>

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
      </div>
    </nav>

    <div class="container">
      <ol class="breadcrumb my-4">
        {% block breadcrumb %}
        {% endblock %}
      </ol>
      {% block content %}
      {% endblock %}
    </div>
  </body>
</html>
image

我所使用的頂部條樣式是:Bootstrap 4 Navbar Component.

我想將標題logo的字體修改一下(.navbar-brand)袱吆。

打開fonts.google.com厌衙,輸入Django Boards或者其他任何你想使用的名稱,點擊apply to all fonts绞绒,檢索到你想使用的字體婶希。

Google Fonts

將字體添加到母模板base.html中:

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Django Boards{% endblock %}</title>
    <link  rel="stylesheet">
    <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
    <link rel="stylesheet" href="{% static 'css/app.css' %}">
  </head>
  <body>
    <!-- code suppressed for brevity -->
  </body>
</html>

然后我們在文件夾static/css下創(chuàng)建一個文件app.css

static/css/app.css

.navbar-brand {
  font-family: 'Peralta', cursive;
}

表單(Forms)

表單用于處理用戶輸入,這是任何web應用程序或網站中非常常見的功能蓬衡。標準的方法是通過HTML表單喻杈,用戶輸入一些數(shù)據(jù),提交給服務器狰晚,然后服務器用它做一些事情筒饰。

用戶輸入是不可靠的

表單處理是一項相當復雜的任務,因為它涉及到與應用程序的許多層進行交互壁晒。還有許多問題需要處理瓷们。例如,提交給服務器的所有數(shù)據(jù)都是字符串格式的秒咐,因此在對其進行任何操作之前谬晕,我們必須將其轉換為適當?shù)臄?shù)據(jù)類型(integer、float携取、date等)攒钳。我們必須驗證與應用程序的業(yè)務邏輯相關的數(shù)據(jù)。我們還必須正確地清理和清理數(shù)據(jù)雷滋,以避免諸如SQL注入和XSS攻擊之類的安全問題不撑。

好消息是Django Forms API使整個過程更加容易,自動化了這項工作的一大部分晤斩。而且焕檬,最終的結果是一個比大多數(shù)程序員自己能夠實現(xiàn)的更安全的代碼。所以澳泵,不管HTML表單有多簡單揩页,都要使用Django自帶的表單API。

如何使用表單

一開始烹俗,我想直接跳到表單API爆侣。但我認為花點時間來理解表單處理的底層細節(jié)是個好主意。否則幢妄,它最終會看起來像魔術兔仰,這是一件壞事,因為當事情出了問題蕉鸳,你不知道該去哪里尋找問題乎赴。

隨著對一些編程概念的深入理解忍法,我們可以感覺到對代碼的可控性更強。掌握控制權很重要榕吼,因為它讓我們更有信心地編寫代碼饿序。一旦我們知道了到底發(fā)生了什么,實現(xiàn)一個可預測行為的代碼就容易多了羹蚣。調試和查找錯誤也容易得多原探,因為知道在哪里去排查。

總之顽素,讓我們從實現(xiàn)下面的表單開始:

這是我們在上一個教程中繪制的線框之一咽弦。我現(xiàn)在意識到這可能是一個不好的例子,因為這個特殊的表單需要處理兩個不同模型的數(shù)據(jù):Topic(subject)和Post(message)胁出。

到目前為止型型,還有一個我們還沒有討論過的重要功能,那就是用戶身份驗證全蝶。我們應該只為經過身份驗證的用戶顯示此屏幕闹蒜。這樣我們就可以知道誰創(chuàng)建了TopicPost

現(xiàn)在讓我們抽象一些細節(jié)抑淫,重點了解如何將用戶輸入的內容保存到數(shù)據(jù)庫中嫂用。

首先,讓我們創(chuàng)建一個名為new_topic的新URL路由:

myproject/urls.py

<details>
<summary>原始版本</summary>

from django.conf.urls import url
from django.contrib import admin

from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
    url(r'^admin/', admin.site.urls),
]

</details>

<details open>
<summary>修訂版本</summary>

from django.urls import re_path
from django.contrib import admin

from boards import views

urlpatterns = [
    re_path(r'^$', views.home, name='home'),
    re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    re_path(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
    re_path(r'^admin/', admin.site.urls),
]

</details>

我們通過這種方式創(chuàng)建的url路由丈冬,可以讓我們在創(chuàng)建主題Topic時知道它屬于那一個版塊Board

現(xiàn)在讓我們來創(chuàng)建new_topic頁面響應方法:

boards/views.py

from django.shortcuts import render, get_object_or_404
from .models import Board

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)
    return render(request, 'new_topic.html', {'board': board})

目前new_topic方法和board_topics方法完全相同甘畅,不著急埂蕊,咱一步一步來。

我們還需要創(chuàng)建一個文件new_topic.html

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}

{% endblock %}

這里我們只實現(xiàn)了頂部導航條的功能疏唾,注意新增了跳轉到版塊下的主題列表頁面board_topics.

打開鏈接http://127.0.0.1:8000/boards/1/new/蓄氧。我們可以看到下面的頁面:

在Django中發(fā)起一個新的主題

我們還沒有為這個頁面編寫入口,直接將鏈接修改為http://127.0.0.1:8000/boards/2/new/槐脏,可以看到發(fā)起的主題切換到了另外一個版塊Python Board

在Python中發(fā)起一個新的主題

提示:
如果您沒有遵循上一教程中的步驟喉童,那么結果可能會有所不同。在我的例子中顿天,數(shù)據(jù)庫中有三個Board實例堂氯,分別是Django=1牌废、Python=2和Random=3排抬。這些數(shù)字是來自數(shù)據(jù)庫的id蹲蒲,從URL用于標識正確的資源模燥。

現(xiàn)在我們增加一點測試用例:

boards/tests.py

# from django.core.urlresolvers import reverse # 注意現(xiàn)在新版本放到了下面
from django.urls import resolve
from django.test import TestCase
from .views import home, board_topics, new_topic
from .models import Board

class HomeTests(TestCase):
    # ...

class BoardTopicsTests(TestCase):
    # ...

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')

    def test_new_topic_view_success_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 200)

    def test_new_topic_view_not_found_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 99})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 404)

    def test_new_topic_url_resolves_new_topic_view(self):
        view = resolve('/boards/1/new/')
        self.assertEquals(view.func, new_topic)

    def test_new_topic_view_contains_link_back_to_board_topics_view(self):
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        response = self.client.get(new_topic_url)
        self.assertContains(response, 'href="{0}"'.format(board_topics_url))

簡單提一下新增的測試用例類NewTopicTests:

  • setUp: 創(chuàng)建了版塊Board示例供測試使用辽旋。
  • test_new_topic_view_success_status_code: 檢查請求頁面的狀態(tài)碼
  • test_new_topic_view_not_found_status_code: 檢查非法請求是否為404
  • test_new_topic_url_resolves_new_topic_view: 檢查是否響應正確的頁面方法
  • test_new_topic_view_contains_link_back_to_board_topics_view: 檢查是否能正常返回到版塊主題列表頁面

運行測試用例:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 0.076s

OK
Destroying test database for alias 'default'...

搞定溶其,讓我們開始創(chuàng)建表單吧。

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    <div class="form-group">
      <label for="id_subject">Subject</label>
      <input type="text" class="form-control" id="id_subject" name="subject">
    </div>
    <div class="form-group">
      <label for="id_message">Message</label>
      <textarea class="form-control" id="id_message" name="message" rows="5"></textarea>
    </div>
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

這是一個用Bootstrap 4的CSS創(chuàng)建的原始HTML表單,它的樣子如下:

<form>標簽中,我們必須定義method屬性,這將決定瀏覽器如何與服務器通信。HTTP規(guī)范定義了幾個請求方法(動詞)驾凶,在大多數(shù)情況下,我們將只使用GETPOST請求類型。

GET可能是最常見的請求類型,它用于從服務器檢索數(shù)據(jù)。每次單擊鏈接或直接在瀏覽器中鍵入URL時,都會創(chuàng)建一個GET請求。

當我們想更改服務器上的數(shù)據(jù)時使用POST腾啥。向服務器發(fā)送數(shù)據(jù)桑谍,而這些數(shù)據(jù)會導致資源狀態(tài)的改變贿条,就應該總是通過POST請求來發(fā)送。

Django使用CSRF Token(Cross-Site Request Forgery Token)保護所有POST請求摄咆。這是一種安全措施涩金,以避免外部站點或應用程序向我們的應用程序提交數(shù)據(jù)全度。每次應用程序收到POST咨堤,它都會首先查找CSRF Token嗜暴。如果請求沒有令牌,或者令牌無效,它將丟棄這次請求的數(shù)據(jù)。

csrf_token模板標記的結果:

{% csrf_token %}

這個實際上是一個和表單數(shù)據(jù)一起提交的隱藏字段:

<input type="hidden" name="csrfmiddlewaretoken" value="jG2o6aWj65YGaqzCpl0TYTg5jn6SctjzRZ9KmluifVx0IVaxlwh97YarZKs54Y32">

需要注意的是砸抛,我們必須為每個提交的HTML字段設置一個name,服務端會通過name來處理和響應請求。

<input type="text" class="form-control" id="id_subject" name="subject">
<textarea class="form-control" id="id_message" name="message" rows="5"></textarea>

下面就是我們如何通過表單和字段名獲取指定的數(shù)據(jù):

subject = request.POST['subject']
message = request.POST['message']

創(chuàng)建新主題的請求響應方法就可以這樣實現(xiàn):

from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .models import Board, Topic, Post

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)

    if request.method == 'POST':
        subject = request.POST['subject']
        message = request.POST['message']

        user = User.objects.first()  # TODO: 獲取當前登錄的用戶臼婆,而不是使用數(shù)據(jù)庫中的第一個用戶

        topic = Topic.objects.create(
            subject=subject,
            board=board,
            starter=user
        )

        post = Post.objects.create(
            message=message,
            topic=topic,
            created_by=user
        )

        return redirect('board_topics', pk=board.pk)  # TODO: redirect to the created topic page

    return render(request, 'new_topic.html', {'board': board})

這個請求響應方法只考慮了理想中的用戶輸入情況彩届,獲取到足夠的數(shù)據(jù)并寫入數(shù)據(jù)庫。但是實際上用戶可能有很多異常提交誓酒,這就需要我們對用戶提交的數(shù)據(jù)進行校驗樟蠕,例如,用戶提交的subject超過255個字符.

因為我們還沒有實現(xiàn)用戶登錄認證的功能靠柑,所以現(xiàn)在我們暴力獲取的數(shù)據(jù)庫中第一個用戶數(shù)據(jù)User。其實我們可以很容易就獲取到當前登錄的用戶,這部分我們在后面的教程中詳細講解钧排。同樣我們也還沒有實現(xiàn)主題Topic的頁面周叮,展示某一主題下的所有帖子Post啥箭,所以當我們新建主題成功后尊蚁,直接跳轉到版塊頁面。

發(fā)起一個新主題

點擊Post按鈕提交表單后:

主題列表

看起來我們成功了购公,讓我們編輯templates/topics.html來展示列表:

templates/topics.html

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <table class="table">
    <thead class="thead-inverse">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in board.topics.all %}
        <tr>
          <td>{{ topic.subject }}</td>
          <td>{{ topic.starter.username }}</td>
          <td>0</td>
          <td>0</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}
主題列表

完美薛躬!Topic展示出來了指巡。

這里有兩個問題需要說明一下:

我們第一次在Board實例中使用topics屬性,這個屬性是由Django利用反向關系自動創(chuàng)建的。在前面我們創(chuàng)建了Topic實例:

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)

    # ...

    topic = Topic.objects.create(
        subject=subject,
        board=board,
        starter=user
    )

board=board這行代碼的意思就是將我們通過pk獲取的board賦值給了新創(chuàng)建的主題實例的外鍵ForeignKey(Board)户魏,這樣賦值以后琳袄,就將這個Topic實例關聯(lián)到了這個版塊Board實例上。

使用board.topics.all而不是board.topics的原因是board.topics是一個關系管理器Related Manager纺酸,類似我們之前提到的用在board.objects上的模型類管理器Model Manager窖逗。 所以如果要訪問該版塊下的的主題列表,就需要通過board.topics.all()去訪問餐蔬。如果需要篩選的話碎紊,可以通過board.topics.filter(subject__contains='Hello')這樣的方式去獲取指定的主題。

另外我們需要注意的是樊诺,在python語法里仗考,調用方法需要使用括號,例如:board.topics.all()词爬。但是在Django模板編寫代碼時秃嗜,我們不使用括號,所以這里是board.topics.all顿膨。

第二個問題就是關于外鍵ForeignKey

{{ topic.starter.username }}

通過符號點.锅锨,我們幾乎可以訪問User模型的任何屬性。例如我們想要用戶的電子郵件恋沃,我們可以使用topic.starter.email.

已經修改了topic.html模板必搞,讓我們再創(chuàng)建一個按鈕跳轉到new_topic頁面:

templates/topics.html

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table">
    <!-- 中間的代碼略了,這里不要照抄 -->
  </table>
{% endblock %}
主題列表

讓我們在創(chuàng)建一個測試用例來確保這個按鈕能跳轉到new_topic頁面:

boards/tests.py

class BoardTopicsTests(TestCase):
    # ...

    def test_board_topics_view_contains_navigation_links(self):
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        homepage_url = reverse('home')
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})

        response = self.client.get(board_topics_url)

        self.assertContains(response, 'href="{0}"'.format(homepage_url))
        self.assertContains(response, 'href="{0}"'.format(new_topic_url))

這里我將test_board_topics_view_contains_link_back_to_homepage直接修改方法名囊咏,再添加了一個校驗assertContains∷≈蓿現(xiàn)在這個測試用例現(xiàn)在檢測所有的頁面跳轉是否正常塔橡。

測試表單頁面

在我們用Django的方式編寫表單示例前,讓我們先寫一點表單處理的測試用例:

boards/tests.py

''' new imports below '''
from django.contrib.auth.models import User
from .views import new_topic
from .models import Board, Topic, Post

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')
        User.objects.create_user(username='john', email='john@doe.com', password='123')  # <- 注意這里

    # ...

    def test_csrf(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertContains(response, 'csrfmiddlewaretoken')

    def test_new_topic_valid_post_data(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': 'Test title',
            'message': 'Lorem ipsum dolor sit amet'
        }
        response = self.client.post(url, data)
        self.assertTrue(Topic.objects.exists())
        self.assertTrue(Post.objects.exists())

    def test_new_topic_invalid_post_data(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.post(url, {})
        self.assertEquals(response.status_code, 200)

    def test_new_topic_invalid_post_data_empty_fields(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': '',
            'message': ''
        }
        response = self.client.post(url, data)
        self.assertEquals(response.status_code, 200)
        self.assertFalse(Topic.objects.exists())
        self.assertFalse(Post.objects.exists())

現(xiàn)在tests.py這個測試文件開始變得越來越大霜第。后面我們會將它拆開來葛家,現(xiàn)在我們先把代碼寫在這里。

  • setUp: 新增了User.objects.create_user來創(chuàng)建User實例用于測試泌类。
  • test_csrf: CSRF TokenPOST請求的必要組成部分癞谒,所以我們必須保證所有網頁都需要包含它。
  • test_new_topic_valid_post_data: 檢測是否發(fā)送有效的數(shù)據(jù)組合末誓,必須創(chuàng)建主題和帖子實例。
  • test_new_topic_invalid_post_data: 檢測傳空數(shù)據(jù)是否按我們預想的進行響應书蚪。
  • test_new_topic_invalid_post_data_empty_fields: 和前一個類似喇澡,我們傳其他的數(shù)據(jù)看應用程序是否校驗了數(shù)據(jù)有效性。

讓我們運行一下這個測試吧:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........EF.....
======================================================================
ERROR: test_new_topic_invalid_post_data (boards.tests.NewTopicTests)
----------------------------------------------------------------------
Traceback (most recent call last):
...
django.utils.datastructures.MultiValueDictKeyError: "'subject'"

======================================================================
FAIL: test_new_topic_invalid_post_data_empty_fields (boards.tests.NewTopicTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/vitorfs/Development/myproject/django-beginners-guide/boards/tests.py", line 115, in test_new_topic_invalid_post_data_empty_fields
    self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200

----------------------------------------------------------------------
Ran 15 tests in 0.512s

FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...

我們的測試有一個失敗和一個錯誤殊校,兩者都與無效的用戶輸入有關晴玖。讓我們使用Django Forms API讓這些測試通過。

使用Django Forms API創(chuàng)建表單

自從我們開始使用表單以來为流,我們已經走了很長的路呕屎,是時候使用forms API了。

Django的django.forms模塊中提供了Forms API敬察。Django主要有兩種方式的表單:forms.Form以及forms.ModelForm秀睛。Form類是一個通用的表單實現(xiàn),我們可以使用它來處理與應用程序中的模型沒有直接關聯(lián)的數(shù)據(jù)莲祸。而ModelFormForm的子類蹂安,它與模型類關聯(lián)。

讓我們創(chuàng)建一個名為forms.pyboards'文件夾中:

boards/forms.py

from django import forms
from .models import Topic

class NewTopicForm(forms.ModelForm):
    message = forms.CharField(widget=forms.Textarea(), max_length=4000)

    class Meta:
        model = Topic
        fields = ['subject', 'message']

這是我們的第一個表單锐帜,它是一個與Topic模型相關聯(lián)的ModelForm田盈。Meta類中fields列表中的subject是指Topic類中的subject字段。現(xiàn)在缴阎,我們定義了一個名為message的額外字段允瞧。這是指我們要保存的Post中的消息。

我們需要重構我們的views.py文件:

boards/views.py

from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .forms import NewTopicForm
from .models import Board, Topic, Post

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)
    user = User.objects.first()  # TODO: get the currently logged in user
    if request.method == 'POST':
        form = NewTopicForm(request.POST)
        if form.is_valid():
            topic = form.save(commit=False)
            topic.board = board
            topic.starter = user
            topic.save()
            post = Post.objects.create(
                message=form.cleaned_data.get('message'),
                topic=topic,
                created_by=user
            )
            return redirect('board_topics', pk=board.pk)  # TODO: redirect to the created topic page
    else:
        form = NewTopicForm()
    return render(request, 'new_topic.html', {'board': board, 'form': form})

這就是我們使用Django表單的方式蛮拔,把無關的代碼屏蔽掉:

if request.method == 'POST':
    form = NewTopicForm(request.POST)
    if form.is_valid():
        topic = form.save()
        return redirect('board_topics', pk=board.pk)
else:
    form = NewTopicForm()
return render(request, 'new_topic.html', {'form': form})

首先述暂,我們檢查請求是POST還是GET。如果請求來自POST建炫,則表示用戶正在向服務器提交一些數(shù)據(jù)贸典。所以我們實例化一個表單實例,將POST數(shù)據(jù)傳遞給表單:form=NewTopicForm(requst.POST)踱卵。

然后廊驼,我們要求Django驗證數(shù)據(jù)据过,檢查表單是否有效,如果我們可以將其保存在數(shù)據(jù)庫中:if form.is_valid():妒挎。如果表單有效绳锅,就將數(shù)據(jù)保存在數(shù)據(jù)庫中form.save()save()方法返回保存到數(shù)據(jù)庫中的模型實例酝掩。由于這是一個Topic表單鳞芙,它將返回創(chuàng)建的Topic實例:topic = form.save()。操作完成后常見的做法是將用戶重定向到其他地方期虾,既可以避免用戶按F5重新提交表單原朝,也可以保證應用程序的流程。

如果數(shù)據(jù)無效镶苞,Django將向表單添加一個錯誤列表喳坠,頁面不執(zhí)行任何操作,并在最后一條語句中返回錯誤:return render(request, 'new_topic.html', {'form': form})茂蚓。這意味著我們必須更新new_topic.html正確顯示錯誤壕鹉。

如果請求是GET,我們只需使用form = NewTopicForm()初始化一個新的空表單聋涨。

讓我們運行測試晾浴,看看一切如何:

python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...............
----------------------------------------------------------------------
Ran 15 tests in 0.522s

OK
Destroying test database for alias 'default'...

我們直接修復了最后這兩個測試。

Django Forms API不僅僅處理和驗證數(shù)據(jù)牍白,它還為我們生成HTML脊凰。

讓我們將new_topic.html改造一下,全部使用Django的表單:

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

form有三種渲染方式:form.as_table茂腥、form.as_ul笙各、form.as_p,通過這些可以快速組織我們需要的數(shù)據(jù)础芍。正如方法名稱所表示的杈抢,as_table使用table標簽來格式化輸入,as_ul則直接生成HTML列表等等仑性。

讓我們看看它的樣子:

我們之前的界面看起來好多了惶楼,對吧?馬上我們就把它改酷炫诊杆。

現(xiàn)在看起來很零散歼捐,但相信我,這背后有很多東西晨汹,它功能非常強大豹储。如果表單有50個字段,則只需輸入{{ form.as_p }}淘这。

而且剥扣,使用forms API后打厘,Django將驗證數(shù)據(jù)并向每個字段添加錯誤消息岖沛。讓我們嘗試提交一個空表單:

Form Validation

** 提示:**
當你提交信息時如果看見這個:

Please fill out this field.
那并不是Django的樣式斧散,這是瀏覽器自帶的格式校驗凭迹。可以添加novalidate屬性來關閉這個樣式晦炊,如<form method="post" novalidate>鞠鲜。
你可以保留這個標簽,沒有任何問題断国。這只是因為我們的表單現(xiàn)在非常簡單贤姆,而且我們沒有太多的數(shù)據(jù)驗證要看。

另一個需要注意的重要事項是:沒有所謂的客戶端驗證稳衬。JavaScript驗證或瀏覽器驗證只是為了可用性目的霞捡。同時還可以減少對服務器的請求數(shù)。數(shù)據(jù)驗證應該始終在服務器端完成宋彼,在服務器端我們應該是可以完全控制數(shù)據(jù)才能保證安全性弄砍。

它還可以自定義提示文案仙畦,可以在Form類或Model類中定義:

boards/forms.py

from django import forms
from .models import Topic

class NewTopicForm(forms.ModelForm):
    message = forms.CharField(
        widget=forms.Textarea(),
        max_length=4000,
        help_text='The max length of the text is 4000.'
    )

    class Meta:
        model = Topic
        fields = ['subject', 'message']
自定義的提示文案

我們還可以為表單字段設置額外的自定義屬性:

boards/forms.py

from django import forms
from .models import Topic

class NewTopicForm(forms.ModelForm):
    message = forms.CharField(
        widget=forms.Textarea(
            attrs={'rows': 5, 'placeholder': 'What is on your mind?'}
        ),
        max_length=4000,
        help_text='The max length of the text is 4000.'
    )

    class Meta:
        model = Topic
        fields = ['subject', 'message']
占位符
自定義Bootstrap Forms樣式

讓我們優(yōu)化一下表單頁面吧输涕。

當使用Bootstrap或者其他前端框架時,我喜歡使用一個Django包django-widget-tweaks慨畸。它使我們能夠更好地控制渲染過程莱坎,保證不影響架構的情況下添加自定義擴展項目。讓我們先安裝這個工具:

pip install django-widget-tweaks

將它添加到項目設置的INSTALLED_APPS里:

myproject/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'widget_tweaks',

    'boards',
]

然后讓我們將它用起來:

templates/new_topic.html

{% extends 'base.html' %}

{% load widget_tweaks %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post" novalidate>
    {% csrf_token %}

    {% for field in form %}
      <div class="form-group">
        {{ field.label_tag }}

        {% render_field field class="form-control" %}

        {% if field.help_text %}
          <small class="form-text text-muted">
            {{ field.help_text }}
          </small>
        {% endif %}
      </div>
    {% endfor %}

    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

好了寸士,這就是使用django-widget-tweaks后的樣子檐什,我們添加了{% load widget_tweaks %}這個模板標簽。然后增加下面的代碼:

{% render_field field class="form-control" %}

render_field標簽不是Django內置的弱卡,它屬于我們剛安裝的第三方框架乃正。使用這個標簽的前提必須在前面添加{% load widget_tweaks %},通過這種方式我們可以根據(jù)特定的條件分配類婶博。

下面是render_field標簽的其他例子:

{% render_field form.subject class="form-control" %}
{% render_field form.message class="form-control" placeholder=form.message.label %}
{% render_field field class="form-control" placeholder="Write a message!" %}
{% render_field field style="font-size: 20px" %}

現(xiàn)在讓我們重新實現(xiàn)一下Bootstrap 4的驗證標簽瓮具,更新到new_topic.html

templates/new_topic.html

<form method="post" novalidate>
  {% csrf_token %}

  {% for field in form %}
    <div class="form-group">
      {{ field.label_tag }}

      {% if form.is_bound %}
        {% if field.errors %}

          {% render_field field class="form-control is-invalid" %}
          {% for error in field.errors %}
            <div class="invalid-feedback">
              {{ error }}
            </div>
          {% endfor %}

        {% else %}
          {% render_field field class="form-control is-valid" %}
        {% endif %}
      {% else %}
        {% render_field field class="form-control" %}
      {% endif %}

      {% if field.help_text %}
        <small class="form-text text-muted">
          {{ field.help_text }}
        </small>
      {% endif %}
    </div>
  {% endfor %}

  <button type="submit" class="btn btn-success">Post</button>
</form>

實現(xiàn)的結果就是:

這里我們有三種不同的狀態(tài):

  • Initial state: 無數(shù)據(jù)狀態(tài)
  • Invalid: 我們添加.is-invalid的CSS class并且為它添加錯誤信息.invalid-feedback,錯誤信息會被渲染成紅色凡人。
  • Valid: 我們添加.is-valid CSS class名党,驗證通過后會渲染成綠色告知用戶可以繼續(xù)填寫。
可復用的表單模板

模板代碼看起來有點復雜挠轴,對吧传睹?好消息是我們可以在整個項目中重用這個片段。

templates文件夾下岸晦,創(chuàng)建一個新的文件夾includes

myproject/
 |-- myproject/
 |    |-- boards/
 |    |-- myproject/
 |    |-- templates/
 |    |    |-- includes/    <-- 這里!
 |    |    |-- base.html
 |    |    |-- home.html
 |    |    |-- new_topic.html
 |    |    +-- topics.html
 |    +-- manage.py
 +-- venv/

再在includes文件夾下創(chuàng)建一個文件form.html

templates/includes/form.html

{% load widget_tweaks %}

{% for field in form %}
  <div class="form-group">
    {{ field.label_tag }}

    {% if form.is_bound %}
      {% if field.errors %}
        {% render_field field class="form-control is-invalid" %}
        {% for error in field.errors %}
          <div class="invalid-feedback">
            {{ error }}
          </div>
        {% endfor %}
      {% else %}
        {% render_field field class="form-control is-valid" %}
      {% endif %}
    {% else %}
      {% render_field field class="form-control" %}
    {% endif %}

    {% if field.help_text %}
      <small class="form-text text-muted">
        {{ field.help_text }}
      </small>
    {% endif %}
  </div>
{% endfor %}

讓我們修改new_topic.html這個模板:

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post" novalidate>
    {% csrf_token %}
    {% include 'includes/form.html' %}
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

通過{% include %}去加載一個其他的HTML模板欧啤,這里我們加載剛創(chuàng)建的表單模板睛藻。

下一個需要實現(xiàn)表單的頁面上,只需要加上{% include 'includes/form.html' %}就可以去自動渲染表單了堂油。

增加更多的測試用例

現(xiàn)在我們使用Django自己的Forms API了修档,讓我們寫一些測試用例來測試它吧:

boards/tests.py

# ... other imports
from .forms import NewTopicForm

class NewTopicTests(TestCase):
    # ... other tests

    def test_contains_form(self):  # <- 新增
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        form = response.context.get('form')
        self.assertIsInstance(form, NewTopicForm)

    def test_new_topic_invalid_post_data(self):  # <- 更新
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.post(url, {})
        form = response.context.get('form')
        self.assertEquals(response.status_code, 200)
        self.assertTrue(form.errors)

這里我們第一次使用assertIsInstance,這里我們從context數(shù)據(jù)中獲取表單實例府框,并檢查它是否是NewTopicForm吱窝。而在我們以前的測試用例中,我們增加了self.assertTrue(form.errors)來確保表單數(shù)據(jù)無效時會顯示錯誤迫靖。


小結

在本教程中院峡,我們重點介紹url、可重用模板和表單系宜。和往常一樣照激,還實現(xiàn)了幾個測試用例,這就是開發(fā)健壯性的基石盹牧。

我們的測試文件開始變得越來越大俩垃,所以在下一個教程中,我們將對其進行重構以提高可維護性汰寓,從而維持代碼庫的健康成長口柳。

我們還需要與登錄用戶進行交互。在下一個教程中有滑,我們將學習有關身份驗證的所有內容以及如何保護我們的數(shù)據(jù)跃闹。

項目的源代碼可以在GitHub上找到。項目的當前狀態(tài)可以在發(fā)布標簽v0.3-lw下找到毛好。下面的鏈接將帶您找到正確的位置:

https://github.com/sibtc/django-beginners-guide/tree/v0.3-lw

上一節(jié):Django初學者入門指南2-基礎知識(譯&改)

下一節(jié):Django初學者入門指南4-安全認證(譯&改)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末望艺,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子肌访,更是在濱河造成了極大的恐慌找默,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吼驶,死亡現(xiàn)場離奇詭異惩激,居然都是意外死亡,警方通過查閱死者的電腦和手機旨剥,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門咧欣,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人轨帜,你說我怎么就攤上這事魄咕。” “怎么了蚌父?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵哮兰,是天一觀的道長毛萌。 經常有香客問我,道長喝滞,這世上最難降的妖魔是什么阁将? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮右遭,結果婚禮上做盅,老公的妹妹穿的比我還像新娘。我一直安慰自己窘哈,他們只是感情好吹榴,可當我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著滚婉,像睡著了一般图筹。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上让腹,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天远剩,我揣著相機與錄音,去河邊找鬼骇窍。 笑死瓜晤,一個胖子當著我的面吹牛,可吹牛的內容都是我干的像鸡。 我是一名探鬼主播活鹰,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼哈恰,長吁一口氣:“原來是場噩夢啊……” “哼只估!你這毒婦竟也來了?” 一聲冷哼從身側響起着绷,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤蛔钙,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后荠医,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體吁脱,經...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年彬向,在試婚紗的時候發(fā)現(xiàn)自己被綠了兼贡。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡娃胆,死狀恐怖遍希,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情里烦,我是刑警寧澤凿蒜,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布禁谦,位于F島的核電站,受9級特大地震影響废封,放射性物質發(fā)生泄漏州泊。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一漂洋、第九天 我趴在偏房一處隱蔽的房頂上張望遥皂。 院中可真熱鬧,春花似錦刽漂、人聲如沸渴肉。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽仇祭。三九已至,卻和暖如春颈畸,著一層夾襖步出監(jiān)牢的瞬間乌奇,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工眯娱, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留礁苗,地道東北人。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓徙缴,卻偏偏與公主長得像试伙,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子于样,可洞房花燭夜當晚...
    茶點故事閱讀 44,611評論 2 353