Django初学者入门指南3-高级概念(译&改)

Django初学者入门指南1-初识(译&改)

Django初学者入门指南2-基础知识(译&改)

Django初学者入门指南3-高级概念(译&改)

Django初学者入门指南4-安全认证(译&改)

Django初学者入门指南5-存储数据(译&改)

Django初学者入门指南6-基于类的页面(译&改)

Django初学者入门指南7-部署发布(译&改)--施工中

>>原文地址 By Vitor Freitas

简介

在本教程中,我们将深入研究两个基本概念:URLsForm。在这个过程中,我们将探讨许多其他概念,如创建可重用模板和安装第三方库。我们还将编写大量的单元测试。

如果您从第一部分开始就遵循本教程系列,编写项目代码并逐步遵循教程,则可能需要在开始前更新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='+')

更新好后,在虚拟环境中执行下面的命令进行迁移更新:

python manage.py makemigrations
python manage.py migrate

如果已经给updated_by属性配置了null=Truelast_updated属性配置了auto_now_add=True,那么你就可以不用进行上面的修改。

如果需要直接使用源代码的话,可以通过GitHub直接获取。当前状态的项目代码可以在发布的标签v0.2-lw下找到,也可以直接点击下面的链接前往获取:

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

接下来我们继续开发吧。


URLs

继续开发我们的应用程序,现在必须实现一个新的页面来列出属于某个给定版块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开发时,经历了一段艰难的时期。

事实上,现在Django开发人员正在研究一个简化路由语法的方案. 但现在在版本1.11里,让我们试着理解它是如何工作的吧。

一个项目可以有很多urls.py在应用程序中分发,但是Django需要一个urls.py作为出发点。这个特别的urls.py称为root URLconf,它被定义在settings.py文件。

myproject/settings.py

ROOT_URLCONF = 'myproject.urls'

它在创建项目时就自动生成好了,这里就不再需要去修改了。

当Django收到请求时,它就会在项目的URLconf中搜索匹配项。它从urlpatterns变量的第一条开始,逐条尝试与请求的url进行匹配。

如果Django找到匹配项,它将通过re_path(url也相同)方法的第二个参数把请求传递给view functionurlpatterns中的顺序很重要,因为Django一旦找到匹配项就会停止搜索。如果Django在URLconf中找不到匹配项,它将引发一个404异常,就是Page Not Found的错误代码。

以下是对urlre_path函数的剖析:

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

# re_path的用法与url完全相同,Django 2.x版本开始,不建议使用url方法,所以使用re_path方法
  • regex: 这个就是用于匹配请求url的正则表达式,需要注意的是它不会匹配到url中的请求参数,比如http://127.0.0.1:8000/boards/?page=2,正则表达式只会尝试匹配/boards/部分,其他则忽略掉了。
  • view: 指定用于响应请求url的页面函数,它同样也可以支持通过include函数引入其他子文件目录的urls.py文件。例如,可以使用它来定义一组特定于应用程序的url,并使用前缀将其包含在根URLconf中。稍后我们将对这个概念进行更多的探讨。
  • kwargs: 传递到目标页面的任意参数,它通常用于对可重用视图进行一些简单的自定义,实际场景中不经常用它。
  • name: 给定URLs的唯一标识符,这是一个非常重要的特性,一定要记住给你的网址命名。通过这种方式,您就可以通过更改regex来更改整个项目中的特定URLs。因此,不要在视图或模板中硬编码URLs,并且始终使用URLs的名称来引用URLs,这一点很重要。
匹配URLs的正则表达式
基础URLs正则表达式

url的创建非常简单,这只是一个字符串匹配的问题。假设我们想要创建一个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>

我们也可以创建更深层级的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路由的例子,针对这个路由,还需要在页面的函数中定义下面的函数:

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匹配特定类型的数据并创建动态URL来实现的。

例如创建一个用户个人资料页面,例如github.com/vitorfs或者twitter.com/vitorfs,其中vitorfs是我的用户名,可以通过下面的方式实现:

<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页面函数。

但是这样定义还是有一些副作用。例如从现在起,必须禁止使用about作为用户名,因为如果选择about作为其用户名,此人将永远看不到他的个人资料页面。

URL路由的那些事

旁注: 如果你想为个人资料页面定义更加合理的URL路由,建议使用/u/vitorfs/或者是/@vitorfs/这样带有前缀标识符的方式。

不过如果你依然想使用前面的方式定义URL路由,那么需要用到一个用户名的禁用文字列表:github.com/shouldbee/reserved-usernames。或者是这个我自学Django时,我自己创建的禁用文字列表:github.com/vitorfs/parsifal/

这样的冲突是非常容易发生的,拿GitHub来说:他们有一个URL路由来展示你当前关注的项目或用户:github.com/watching。如果有人用watching作为用户名,那么他就没有办法访问到他自己的个人资料页面。同样的,通过github.com/watching/repositories我们本该看到我们关注的项目,但是却可能访问到这个用户的项目列表,类似我的项目列表:github.com/vitorfs/repositories

这种URL路由的整体思想是创建动态页面,其中URL的一部分将用作某个资源的标识符,该资源将用于组成页面。例如,该标识符可以是整数ID或字符串。

首先,我们将使用Board的ID为Topic创建一个动态页面,让我们再看看在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+匹配的是至少一位数字的整数。我们会通过这个数字去数据库中查询Board类型的实例。再看下正则表达式的写法(?P<pk>\d+),这种写法会让Django将这个正则表达式匹配到的字符串赋值给变量pk

所以对应的在views.py中实现方法:

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

因为我们使用了(?P<pk>\d+)这样的正则表达式,所以在board_topics接收的变量就必须是pk

如果不需要指定该参数的名称,那么可以使用下面的写法:

<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...

定义成什么样的名字不重要,使用命名参数可以让我们更清楚url中匹配的各个参数,在使用更多变量更大url时,更加容易理解。

旁注: PK 和 ID 的区别?
PK 就是Primary Key,它是访问模型主键的快捷方式,所有Django模型都有这个属性。
在大多数情况下,使用pk属性与id相同。这是因为如果我们不为模型定义主键,Django将自动创建一个名为idAutoField并默认为主键。但如果你为一个模型定义了一个不同的主键,假设字段email是自定义的主键。这时就可以使用obj.email或者obj.pk去访问它了。

使用URLs API

是时候写些代码了。让我们来实现版块的主题列表页面吧。

版块内容,显示该版块下的所有主题

首先在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>

然后再实现页面方法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>

提示: 现在我们先临时创建固定的html文件,在后续的教程中会教大家使用可复用的html模板.

现在我们在浏览器中打开http://127.0.0.1:8000/boards/1/,应该会看到如下的页面:

主题列表

让我们写一点测试用例吧,打开tests.py并添加如下内容:

boards/tests.py

# from django.core.urlresolvers import reverse # 注意现在新版本放到了下面
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方法,在这个方法里,我们创建了一个Board实例来执行测试用例。因为Django测试工具不会对当前数据库进行测试。为了运行测试,Django会动态创建一个新数据库,应用所有模型迁移,运行测试,完成后销毁测试数据库。

所以我们需要在setUp方法中准备用于测试的数据,以便模拟测试场景。

  • test_board_topics_view_success_status_code:用于检测是否能为当前已有的Board对象数据返回正确的状态码(200)。
  • test_board_topics_view_not_found_status_code:用于检测是否能为当前没有的Board对象数据返回找不到数据的状态码(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的生产环境中,访问者将看到一个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有一个现成的方法来返回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'...

妥了,我们继续后面的开发吧。

下一步是在屏幕中创建导航链接。主页应该有一个链接,将访问者链接到一个给定的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))

注意现在也为HomeTests添加了一个setUp方法,这是因为现在我们需要一个Board实例,同时还将urlresponse移动到setUp,这样就可以在新的测试中重用相同的参数了。

这里的新测试是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'...

让我们继续修改代码来通过这个单元测试。

修改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。第一个参数是URL的name(在URLconf中定义,即urls.py),则可以根据需要传递任意数量的参数。

如果它是一个像主页那样的简单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'...

好了,我们可以在浏览器里查看了:

现在我们来写回到首页的代码,先写测试用例:

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应用程序的一个基础部分。有了这些知识,我们就可以继续开发了。接下来,我会给出一些常用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文档,从长远来看这是不可持续的。这也是一种不好的做法。

在本节中,我们将重构HTML模板,抽出可复用的部分,创建一个master page,并且只在各自模板写它独有的代码。

templates文件夹中创建一个名为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>

这将是我们的基础页面,往后创建的每个模板,都会extends这个特殊的模板。注意现在我们引入了{% block %}标记,它将在模板中预留一个位置,子模板(扩展该模板页面的其他页面)可以在该位置中插入代码和HTML。

{% block title %}的这个位置,我们还设置了一个默认值,即Django Boards。如果我们没有在子模板中为{% block title %}设置值,则会使用该默认值。

现在让我们重构两个模板: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 %}

注意现在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版块的页面,标题就会变为Python - Django Boards,而Random版块的标题就会变为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'...

完美!所有功能正常。

使用现在的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 href="https://fonts.googleapis.com/css?family=Peralta" 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下创建一个文件app.css

static/css/app.css

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

表单(Forms)

表单用于处理用户输入,这是任何web应用程序或网站中非常常见的功能。标准的方法是通过HTML表单,用户输入一些数据,提交给服务器,然后服务器用它做一些事情。

用户输入是不可靠的

表单处理是一项相当复杂的任务,因为它涉及到与应用程序的许多层进行交互。还有许多问题需要处理。例如,提交给服务器的所有数据都是字符串格式的,因此在对其进行任何操作之前,我们必须将其转换为适当的数据类型(integer、float、date等)。我们必须验证与应用程序的业务逻辑相关的数据。我们还必须正确地清理和清理数据,以避免诸如SQL注入和XSS攻击之类的安全问题。

好消息是Django Forms API使整个过程更加容易,自动化了这项工作的一大部分。而且,最终的结果是一个比大多数程序员自己能够实现的更安全的代码。所以,不管HTML表单有多简单,都要使用Django自带的表单API。

如何使用表单

一开始,我想直接跳到表单API。但我认为花点时间来理解表单处理的底层细节是个好主意。否则,它最终会看起来像魔术,这是一件坏事,因为当事情出了问题,你不知道该去哪里寻找问题。

随着对一些编程概念的深入理解,我们可以感觉到对代码的可控性更强。掌握控制权很重要,因为它让我们更有信心地编写代码。一旦我们知道了到底发生了什么,实现一个可预测行为的代码就容易多了。调试和查找错误也容易得多,因为知道在哪里去排查。

总之,让我们从实现下面的表单开始:

这是我们在上一个教程中绘制的线框之一。我现在意识到这可能是一个不好的例子,因为这个特殊的表单需要处理两个不同模型的数据:Topic(subject)和Post(message)。

到目前为止,还有一个我们还没有讨论过的重要功能,那就是用户身份验证。我们应该只为经过身份验证的用户显示此屏幕。这样我们就可以知道谁创建了TopicPost

现在让我们抽象一些细节,重点了解如何将用户输入的内容保存到数据库中。

首先,让我们创建一个名为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>

我们通过这种方式创建的url路由,可以让我们在创建主题Topic时知道它属于那一个版块Board

现在让我们来创建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方法完全相同,不着急,咱一步一步来。

我们还需要创建一个文件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 %}

这里我们只实现了顶部导航条的功能,注意新增了跳转到版块下的主题列表页面board_topics.

打开链接http://127.0.0.1:8000/boards/1/new/。我们可以看到下面的页面:

在Django中发起一个新的主题

我们还没有为这个页面编写入口,直接将链接修改为http://127.0.0.1:8000/boards/2/new/,可以看到发起的主题切换到了另外一个版块Python Board

在Python中发起一个新的主题

提示:
如果您没有遵循上一教程中的步骤,那么结果可能会有所不同。在我的例子中,数据库中有三个Board实例,分别是Django=1、Python=2和Random=3。这些数字是来自数据库的id,从URL用于标识正确的资源。

现在我们增加一点测试用例:

boards/tests.py

# from django.core.urlresolvers import reverse # 注意现在新版本放到了下面
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: 创建了版块Board示例供测试使用。
  • test_new_topic_view_success_status_code: 检查请求页面的状态码
  • 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'...

搞定,让我们开始创建表单吧。

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创建的原始HTML表单,它的样子如下:

<form>标签中,我们必须定义method属性,这将决定浏览器如何与服务器通信。HTTP规范定义了几个请求方法(动词),在大多数情况下,我们将只使用GETPOST请求类型。

GET可能是最常见的请求类型,它用于从服务器检索数据。每次单击链接或直接在浏览器中键入URL时,都会创建一个GET请求。

当我们想更改服务器上的数据时使用POST。向服务器发送数据,而这些数据会导致资源状态的改变,就应该总是通过POST请求来发送。

Django使用CSRF Token(Cross-Site Request Forgery Token)保护所有POST请求。这是一种安全措施,以避免外部站点或应用程序向我们的应用程序提交数据。每次应用程序收到POST,它都会首先查找CSRF Token。如果请求没有令牌,或者令牌无效,它将丢弃这次请求的数据。

csrf_token模板标记的结果:

{% csrf_token %}

这个实际上是一个和表单数据一起提交的隐藏字段:

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

下面就是我们如何通过表单和字段名获取指定的数据:

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

创建新主题的请求响应方法就可以这样实现:

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: 获取当前登录的用户,而不是使用数据库中的第一个用户

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

这个请求响应方法只考虑了理想中的用户输入情况,获取到足够的数据并写入数据库。但是实际上用户可能有很多异常提交,这就需要我们对用户提交的数据进行校验,例如,用户提交的subject超过255个字符.

因为我们还没有实现用户登录认证的功能,所以现在我们暴力获取的数据库中第一个用户数据User。其实我们可以很容易就获取到当前登录的用户,这部分我们在后面的教程中详细讲解。同样我们也还没有实现主题Topic的页面,展示某一主题下的所有帖子Post,所以当我们新建主题成功后,直接跳转到版块页面。

发起一个新主题

点击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利用反向关系自动创建的。在前面我们创建了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赋值给了新创建的主题实例的外键ForeignKey(Board),这样赋值以后,就将这个Topic实例关联到了这个版块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模板,让我们再创建一个按钮跳转到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 %}
主题列表

让我们在创建一个测试用例来确保这个按钮能跳转到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。现在这个测试用例现在检测所有的页面跳转是否正常。

测试表单页面

在我们用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())

现在tests.py这个测试文件开始变得越来越大。后面我们会将它拆开来,现在我们先把代码写在这里。

  • setUp: 新增了User.objects.create_user来创建User实例用于测试。
  • test_csrf: CSRF TokenPOST请求的必要组成部分,所以我们必须保证所有网页都需要包含它。
  • test_new_topic_valid_post_data: 检测是否发送有效的数据组合,必须创建主题和帖子实例。
  • test_new_topic_invalid_post_data: 检测传空数据是否按我们预想的进行响应。
  • test_new_topic_invalid_post_data_empty_fields: 和前一个类似,我们传其他的数据看应用程序是否校验了数据有效性。

让我们运行一下这个测试吧:

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创建表单

自从我们开始使用表单以来,我们已经走了很长的路,是时候使用forms API了。

Django的django.forms模块中提供了Forms API。Django主要有两种方式的表单:forms.Form以及forms.ModelFormForm类是一个通用的表单实现,我们可以使用它来处理与应用程序中的模型没有直接关联的数据。而ModelFormForm的子类,它与模型类关联。

让我们创建一个名为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模型相关联的ModelFormMeta类中fields列表中的subject是指Topic类中的subject字段。现在,我们定义了一个名为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,则表示用户正在向服务器提交一些数据。所以我们实例化一个表单实例,将POST数据传递给表单:form=NewTopicForm(requst.POST)

然后,我们要求Django验证数据,检查表单是否有效,如果我们可以将其保存在数据库中:if form.is_valid():。如果表单有效,就将数据保存在数据库中form.save()save()方法返回保存到数据库中的模型实例。由于这是一个Topic表单,它将返回创建的Topic实例:topic = form.save()。操作完成后常见的做法是将用户重定向到其他地方,既可以避免用户按F5重新提交表单,也可以保证应用程序的流程。

如果数据无效,Django将向表单添加一个错误列表,页面不执行任何操作,并在最后一条语句中返回错误: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不仅仅处理和验证数据,它还为我们生成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_tableform.as_ulform.as_p,通过这些可以快速组织我们需要的数据。正如方法名称所表示的,as_table使用table标签来格式化输入,as_ul则直接生成HTML列表等等。

让我们看看它的样子:

我们之前的界面看起来好多了,对吧?马上我们就把它改酷炫。

现在看起来很零散,但相信我,这背后有很多东西,它功能非常强大。如果表单有50个字段,则只需输入{{ form.as_p }}

而且,使用forms API后,Django将验证数据并向每个字段添加错误消息。让我们尝试提交一个空表单:

Form Validation

** 提示:**
当你提交信息时如果看见这个:

Please fill out this field.
那并不是Django的样式,这是浏览器自带的格式校验。可以添加novalidate属性来关闭这个样式,如<form method="post" novalidate>
你可以保留这个标签,没有任何问题。这只是因为我们的表单现在非常简单,而且我们没有太多的数据验证要看。

另一个需要注意的重要事项是:没有所谓的客户端验证。JavaScript验证或浏览器验证只是为了可用性目的。同时还可以减少对服务器的请求数。数据验证应该始终在服务器端完成,在服务器端我们应该是可以完全控制数据才能保证安全性。

它还可以自定义提示文案,可以在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样式

让我们优化一下表单页面吧。

当使用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 %},通过这种方式我们可以根据特定的条件分配类。

下面是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" %}

现在让我们重新实现一下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>

实现的结果就是:

这里我们有三种不同的状态:

  • Initial state: 无数据状态
  • Invalid: 我们添加.is-invalid的CSS class并且为它添加错误信息.invalid-feedback,错误信息会被渲染成红色。
  • Valid: 我们添加.is-valid CSS class,验证通过后会渲染成绿色告知用户可以继续填写。
可复用的表单模板

模板代码看起来有点复杂,对吧?好消息是我们可以在整个项目中重用这个片段。

templates文件夹下,创建一个新的文件夹includes

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

再在includes文件夹下创建一个文件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模板,这里我们加载刚创建的表单模板。

下一个需要实现表单的页面上,只需要加上{% include 'includes/form.html' %}就可以去自动渲染表单了。

增加更多的测试用例

现在我们使用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数据中获取表单实例,并检查它是否是NewTopicForm。而在我们以前的测试用例中,我们增加了self.assertTrue(form.errors)来确保表单数据无效时会显示错误。


小结

在本教程中,我们重点介绍url、可重用模板和表单。和往常一样,还实现了几个测试用例,这就是开发健壮性的基石。

我们的测试文件开始变得越来越大,所以在下一个教程中,我们将对其进行重构以提高可维护性,从而维持代码库的健康成长。

我们还需要与登录用户进行交互。在下一个教程中,我们将学习有关身份验证的所有内容以及如何保护我们的数据。

项目的源代码可以在GitHub上找到。项目的当前状态可以在发布标签v0.3-lw下找到。下面的链接将带您找到正确的位置:

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

上一节:Django初学者入门指南2-基础知识(译&改)

下一节:Django初学者入门指南4-安全认证(译&改)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335