Django初学者入门指南1-初识(译&改)
Django初学者入门指南2-基础知识(译&改)
Django初学者入门指南3-高级概念(译&改)
Django初学者入门指南4-安全认证(译&改)
Django初学者入门指南5-存储数据(译&改)
Django初学者入门指南6-基于类的页面(译&改)
Django初学者入门指南7-部署发布(译&改)--施工中
>>原文地址 By Vitor Freitas
简介
在本教程中,我们将深入研究两个基本概念:URLs和Form。在这个过程中,我们将探讨许多其他概念,如创建可重用模板和安装第三方库。我们还将编写大量的单元测试。
如果您从第一部分开始就遵循本教程系列,编写项目代码并逐步遵循教程,则可能需要在开始前更新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=True
,last_updated
属性配置了auto_now_add=True
,那么你就可以不用进行上面的修改。
如果需要直接使用源代码的话,可以通过GitHub直接获取。当前状态的项目代码可以在发布的标签v0.2-lw下找到,也可以直接点击下面的链接前往获取:
https://github.com/sibtc/django-beginners-guide/tree/v0.2-lw
接下来我们继续开发吧。
URLs
继续开发我们的应用程序,现在必须实现一个新的页面来列出属于某个给定版块Board的所有主题Topic。简单回顾一下,下面可以看到我们在上一个教程中绘制的线框图:
我们先来编辑myproject目录下的urls.py文件:
原始版本
原始版本的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\d+)/$', views.board_topics, name='board_topics'),
url(r'^admin/', admin.site.urls),
]
修订版本的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\d+)/$', views.board_topics, name='board_topics'),
re_path(r'^admin/', admin.site.urls),
]
这次让我们花点时间分析一下urlpatterns
和url
。
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 function。urlpatterns
中的顺序很重要,因为Django一旦找到匹配项就会停止搜索。如果Django在URLconf中找不到匹配项,它将引发一个404异常,就是Page Not Found的错误代码。
以下是对url
和re_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正则表达式
url的创建非常简单,这只是一个字符串匹配的问题。假设我们想要创建一个about页面,可以这样定义:
myproject/urls.py
原始版本
from django.conf.urls import url
from boards import views
urlpatterns = [
url(r'^$', views.home, name='home'),
url(r'^about/$', views.about, name='about'),
]
修订版本
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'),
]
我们也可以创建更深层级的URL:
原始版本
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'),
]
修订版本
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'),
]
以上都是一些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
是我的用户名,可以通过下面的方式实现:
原始版本
from django.conf.urls import url
from boards import views
urlpatterns = [
url(r'^$', views.home, name='home'),
url(r'^(?P[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]
修订版本
from django.urls import re_path
from boards import views
urlpatterns = [
re_path(r'^$', views.home, name='home'),
re_path(r'^(?P[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]
通过这种方式,将匹配到Django用户模型的所有有效用户的姓名。
或许你注意到了,上面的URL正则匹配的范围非常大,因为它定义在了根url而不是类似于/profile/
原始版本
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[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]
修订版本
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[\w.@+-]+)/$', views.user_profile, name='user_profile'),
]
如果about
页面是在username URL模式之后定义,Django将永远找不到它,因为单词about
将匹配username的正则表达式,并且由页面user_profile
响应而不是about
页面函数。
但是这样定义还是有一些副作用。例如从现在起,必须禁止使用about
作为用户名,因为如果选择about
作为其用户名,此人将永远看不到他的个人资料页面。
旁注: 如果你想为个人资料页面定义更加合理的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部分开头给出的示例:
原始版本
url(r'^boards/(?P\d+)/$', views.board_topics, name='board_topics')
修订版本
re_path(r'^boards/(?P\d+)/$', views.board_topics, name='board_topics')
正则表达式\d+
匹配的是至少一位数字的整数。我们会通过这个数字去数据库中查询Board类型的实例。再看下正则表达式的写法(?P
,这种写法会让Django将这个正则表达式匹配到的字符串赋值给变量pk
。
所以对应的在views.py中实现方法:
def board_topics(request, pk):
# do something...
因为我们使用了(?P
这样的正则表达式,所以在board_topics
接收的变量就必须是pk
。
如果不需要指定该参数的名称,那么可以使用下面的写法:
原始版本
url(r'^boards/(\d+)/$', views.board_topics, name='board_topics')
修订版本
re_path(r'^boards/(\d+)/$', views.board_topics, name='board_topics')
这样我们就可以定义成:
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将自动创建一个名为id
的AutoField
并默认为主键。但如果你为一个模型定义了一个不同的主键,假设字段obj.email
或者obj.pk
去访问它了。
使用URLs API
是时候写些代码了。让我们来实现版块的主题列表页面吧。
首先在urls.py中添加一个新的url路由:
原始版本
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\d+)/$', views.board_topics, name='board_topics'),
url(r'^admin/', admin.site.urls),
]
修订版本
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\d+)/$', views.board_topics, name='board_topics'),
re_path(r'^admin/', admin.site.urls),
]
然后再实现页面方法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 %}
{{ board.name }}
提示: 现在我们先临时创建固定的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.
在配置为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'...
好了!这就是我们想要的结果。
这是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实例,同时还将url和response移动到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
{% for board in boards %}
{{ board.name }}
{{ board.description }}
0
0
{% endfor %}
这里主要是将以前的:
{{ board.name }}
修改为:
{{ board.name }}
始终使用{% 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 %}
运行测试:
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 |
Example | url(r'^questions/(?P |
Valid URL | /questions/934/ |
Captures | {'pk': '934'} |
Slug Field | Value |
---|---|
Regex | (?P |
Example | url(r'^posts/(?P |
Valid URL | /posts/hello-world/ |
Captures | {'slug': 'hello-world'} |
Slug Field with Primary Key | Value |
---|---|
Regex | (?P |
Example | url(r'^blog/(?P |
Valid URL | /blog/hello-world-159/ |
Captures | {'slug': 'hello-world', 'pk': '159'} |
Django User Username | Value |
---|---|
Regex | (?P |
Example | url(r'^profile/(?P |
Valid URL | /profile/vitorfs/ |
Captures | {'username': 'vitorfs'} |
Year | Value |
---|---|
Regex | (?P |
Example | url(r'^articles/(?P |
Valid URL | /articles/2016/ |
Captures | {'year': '2016'} |
Year / Month | Value |
---|---|
Regex | (?P |
Example | url(r'^articles/(?P |
Valid URL | /articles/2016/01/ |
Captures | {'year': '2016', 'month': '01'} |
如果需要查看更多其他例子,可以前往这里查看:常用URL Patterns.
可复用的模板
到目前为止,我们常常在复制和粘贴相同的内容到HTML文档,从长远来看这是不可持续的。这也是一种不好的做法。
在本节中,我们将重构HTML模板,抽出可复用的部分,创建一个master page,并且只在各自模板写它独有的代码。
在templates文件夹中创建一个名为base.html的文件:
templates/base.html
{% load static %}
{% block title %}Django Boards{% endblock %}
{% block content %}
{% endblock %}
这将是我们的基础页面,往后创建的每个模板,都会extends这个特殊的模板。注意现在我们引入了{% block %}
标记,它将在模板中预留一个位置,子模板(扩展该模板页面的其他页面)可以在该位置中插入代码和HTML。
在{% block title %}
的这个位置,我们还设置了一个默认值,即Django Boards
。如果我们没有在子模板中为{% block title %}
设置值,则会使用该默认值。
现在让我们重构两个模板:home.html以及topics.html.
templates/home.html
{% extends 'base.html' %}
{% block breadcrumb %}
Boards
{% endblock %}
{% block content %}
Board
Posts
Topics
Last Post
{% for board in boards %}
{{ board.name }}
{{ board.description }}
0
0
{% endfor %}
{% 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 %}
Boards
{{ board.name }}
{% 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 %}
{% block title %}Django Boards{% endblock %}
{% block content %}
{% endblock %}
我所使用的顶部条样式是:Bootstrap 4 Navbar Component.
我想将标题logo
的字体修改一下(.navbar-brand
)。
打开fonts.google.com,输入Django Boards
或者其他任何你想使用的名称,点击apply to all fonts,检索到你想使用的字体。
将字体添加到母模板base.html中:
{% load static %}
{% block title %}Django Boards{% endblock %}
然后我们在文件夹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)。
到目前为止,还有一个我们还没有讨论过的重要功能,那就是用户身份验证。我们应该只为经过身份验证的用户显示此屏幕。这样我们就可以知道谁创建了Topic或Post。
现在让我们抽象一些细节,重点了解如何将用户输入的内容保存到数据库中。
首先,让我们创建一个名为new_topic的新URL路由:
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\d+)/$', views.board_topics, name='board_topics'),
url(r'^boards/(?P\d+)/new/$', views.new_topic, name='new_topic'),
url(r'^admin/', admin.site.urls),
]
修订版本
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\d+)/$', views.board_topics, name='board_topics'),
re_path(r'^boards/(?P\d+)/new/$', views.new_topic, name='new_topic'),
re_path(r'^admin/', admin.site.urls),
]
我们通过这种方式创建的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 %}
Boards
{{ board.name }}
New topic
{% endblock %}
{% block content %}
{% endblock %}
这里我们只实现了顶部导航条的功能,注意新增了跳转到版块下的主题列表页面board_topics.
打开链接http://127.0.0.1:8000/boards/1/new/。我们可以看到下面的页面:
我们还没有为这个页面编写入口,直接将链接修改为http://127.0.0.1:8000/boards/2/new/,可以看到发起的主题切换到了另外一个版块Python Board:
提示:
如果您没有遵循上一教程中的步骤,那么结果可能会有所不同。在我的例子中,数据库中有三个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 %}
Boards
{{ board.name }}
New topic
{% endblock %}
{% block content %}
{% endblock %}
这是一个用Bootstrap 4的CSS创建的原始HTML表单,它的样子如下:
在标签中,我们必须定义
method
属性,这将决定浏览器如何与服务器通信。HTTP规范定义了几个请求方法(动词),在大多数情况下,我们将只使用GET和POST请求类型。
GET可能是最常见的请求类型,它用于从服务器检索数据。每次单击链接或直接在浏览器中键入URL时,都会创建一个GET请求。
当我们想更改服务器上的数据时使用POST。向服务器发送数据,而这些数据会导致资源状态的改变,就应该总是通过POST请求来发送。
Django使用CSRF Token(Cross-Site Request Forgery Token)保护所有POST请求。这是一种安全措施,以避免外部站点或应用程序向我们的应用程序提交数据。每次应用程序收到POST,它都会首先查找CSRF Token。如果请求没有令牌,或者令牌无效,它将丢弃这次请求的数据。
csrf_token模板标记的结果:
{% csrf_token %}
这个实际上是一个和表单数据一起提交的隐藏字段:
需要注意的是,我们必须为每个提交的HTML字段设置一个name,服务端会通过name来处理和响应请求。
下面就是我们如何通过表单和字段名获取指定的数据:
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 %}
Boards
{{ board.name }}
{% endblock %}
{% block content %}
Topic
Starter
Replies
Views
Last Update
{% for topic in board.topics.all %}
{{ topic.subject }}
{{ topic.starter.username }}
0
0
{{ topic.last_updated }}
{% endfor %}
{% 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 %}
{% 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='[email protected]', 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 Token是POST请求的必要组成部分,所以我们必须保证所有网页都需要包含它。 -
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.ModelForm
。Form
类是一个通用的表单实现,我们可以使用它来处理与应用程序中的模型没有直接关联的数据。而ModelForm
是Form
的子类,它与模型类关联。
让我们创建一个名为forms.py在boards'文件夹中:
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模型相关联的ModelForm
。Meta类中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 %}
Boards
{{ board.name }}
New topic
{% endblock %}
{% block content %}
{% endblock %}
form
有三种渲染方式:form.as_table
、form.as_ul
、form.as_p
,通过这些可以快速组织我们需要的数据。正如方法名称所表示的,as_table
使用table标签来格式化输入,as_ul
则直接生成HTML列表等等。
让我们看看它的样子:
我们之前的界面看起来好多了,对吧?马上我们就把它改酷炫。
现在看起来很零散,但相信我,这背后有很多东西,它功能非常强大。如果表单有50个字段,则只需输入{{ form.as_p }}
。
而且,使用forms API后,Django将验证数据并向每个字段添加错误消息。让我们尝试提交一个空表单:
** 提示:**
当你提交信息时如果看见这个:那并不是Django的样式,这是浏览器自带的格式校验。可以添加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 %}
Boards
{{ board.name }}
New topic
{% endblock %}
{% block content %}
{% 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
实现的结果就是:
这里我们有三种不同的状态:
- 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 %}
{{ field.label_tag }}
{% if form.is_bound %}
{% if field.errors %}
{% render_field field class="form-control is-invalid" %}
{% for error in field.errors %}
{{ error }}
{% endfor %}
{% else %}
{% render_field field class="form-control is-valid" %}
{% endif %}
{% else %}
{% render_field field class="form-control" %}
{% endif %}
{% if field.help_text %}
{{ field.help_text }}
{% endif %}
{% endfor %}
让我们修改new_topic.html这个模板:
templates/new_topic.html
{% extends 'base.html' %}
{% block title %}Start a New Topic{% endblock %}
{% block breadcrumb %}
Boards
{{ board.name }}
New topic
{% endblock %}
{% block content %}
{% 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-安全认证(译&改)