第一个Django应用

本文以创建一个Web投票应用为例,使用Django开发Web应用,进一步了解Django的功能。本次应用使用Pycharm进行开发。

该应用(app)包括以下两个部分:

  • 一个可以让公众用户进行投票和查看投票结果的站点
  • 一个可以进行增、删、改、查的后台管理界面,也就是我们常说的admin站点

一、新建项目

在Pycharm中新建一个Django项目(专业版Pycharm才支持Django)

指定项目路径目录和python版本环境

image-20211015091131961

这将在指定项目目录下生成一个mysite目录,也就是你的这个Django项目的根目录。它包含了一系列自动生成的目录和文件,具备各自专有的用途。

注意:在给项目命名的时候必须避开Django和Python的保留关键字,比如“django”,“test”等,否则会引起冲突和莫名的错误。

二、创建投票应用(app)

在 Django 中,每一个应用(app)都是一个 Python 包,并且遵循着相同的约定。Django 自带一个工具,可以帮你生成应用的基础目录结构。

app应用与project项目的区别:

  • 一个app实现某个具体功能,比如博客、公共档案数据库或者简单的投票系统;
  • 一个project是配置文件和多个app的集合,这些app组合成整个站点;
  • 一个project可以包含多个app;
  • 一个app可以属于多个project!

app的存放位置可以是任何地点,但是通常都将它们放在与manage.py脚本同级的目录下,这样方便导入文件。

在Pycharm中,没有可以创建app的图形化按钮,需要在下方的Terminal终端中输入命令(后面命令行都是在此终端输入):

1
python manage.py startapp polls

image-20211015091838979

通过前面在Pycharm中创建工程的方式有个方便之处,点击Terminal后,会自动进入虚拟环境。

三、编写第一个视图

开始编写第一个视图

polls/views.py文件中,编写代码:

1
2
3
4
from django.http import HttpResponse

def index(request):
return HttpResponse("这里是orangecola的投票站点")

为了调用该视图,我们还需要编写urlconf,也就是路由配置。

在polls目录中新建一个文件或者将项目下的总urls.py复制过来,名字为urls.py(不要换成别的名字),在其中输入或修改代码如下:

1
2
3
4
5
6
7
8
9
from django.urls import path
from . import views

urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/results/', views.results, name='results'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]

接下来,在项目的主urls.py文件中添加urlpattern条目,指向我们刚才建立的polls这个app独有的urls.py文件,这里需要导入include模块。打开mysite/urls.py文件,代码如下:

1
2
3
4
5
6
7
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path('admin/', admin.site.urls),
path('polls/', include('polls.urls')),
]

include语法相当于多级路由,它把接收到的url地址去除与此项匹配的部分,将剩下的字符串传递给下一级路由urlconf进行判断。在路由的章节,有更加详细的用法指导。

建议:除了admin路由外,尽量给每个app设计自己独立的二级路由。

启动服务器,在浏览器中访问地址http://localhost:8000/polls/

一切正常的话,你将看到这里是orangecola的投票站点

四、settings和数据库配置

在myweb/settings.py文件中,通过DATABASES项进行数据库设置

需要提前创建好mysite数据库

1
2
3
4
5
6
7
8
9
10
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'mysite',
'USER': 'root',
'PASSWORD': '',
'HOST': 'localhost',
'PORT': '3306',
}
}

注意:Django使用MySQL数据库需要加载 MySQLdb模块,需要安装 mysqlclient,若已经安装请略过

1
pip install  mysqlclient

在修改settings文件时,请顺便将TIME_ZONE设置为国内所在的时区Asia/Shanghai,这样显示的就是我们北京时间。

以及将创建的app加入到settings的INSTALLED_APPS设置项中,它列出了所有的项目中被激活的Django应用(app),你必须将你自己创建的app注册在这里

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'polls',
]

五、创建模型

进行定义模型,模型本质上就是数据库表的布局,再附加一些元数据。

Django通过自定义Python类的形式来定义具体的模型,每个模型的物理存在方式就是一个Python的类Class,每个模型代表数据库中的一张表,每个类的实例代表数据表中的一行数据,类中的每个变量代表数据表中的一列字段。

在这个应用中我们需要创建两个模型QuestionChoice

Question包含一个问题和一个发布日期。Choice包含两个字段:该选项的文本描述和该选项的投票数。每一条Choice都关联到一个Question。

编辑polls/models.py文件,编写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import datetime

from django.db import models
from django.utils import timezone


class Question(models.Model):
# 问题
question_text = models.CharField(max_length=200)
# 发布日期
pub_date = models.DateTimeField('date published')

def __str__(self):
return self.question_text

# 是否在当前发布的问卷
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)


class Choice(models.Model):
# 外键关联到Question
question = models.ForeignKey(Question, on_delete=models.CASCADE)
# 文本描述
choice_text = models.CharField(max_length=200)
# 投票数
votes = models.IntegerField(default=0)

def __str__(self):
return self.choice_text

每一个类都是django.db.models.Model的子类。每一个字段都是Field类的一个实例,例如用于保存文本数据的CharField和用于保存时间类型的DateTimeField,它们告诉Django每一个字段保存的数据类型。

每一个Field实例的名字就是字段的名字(如: question_text 或者 pub_date )。在你的Python代码中会使用这个值,你的数据库也会将这个值作为表的列名。

一些Field类必须提供某些特定的参数。例如CharField需要你指定max_length。这不仅是数据库结构的需要,同样也用于数据验证功能。

启用模型

在命令行输入:

1
python manage.py makemigrations polls

通过运行makemigrations命令,Django 会检测你对模型文件的修改,也就是告诉Django你对模型有改动,并且你想把这些改动保存为一个“迁移(migration)”。

接下来有一个叫做migrate的命令将对数据库执行真正的迁移动作。

进行数据迁移:

1
python manage.py migrate

后续只需记住修改模型时的操作分三步

在models.py中修改模型;

运行python manage.py makemigrations为改动创建迁移记录文件;

运行python manage.py migrate,将操作迁移同步到数据库。

六、体验模型自带的API

Django模型层自带ORM系统,会自动为每个模型创建数据库访问的API,直接拿来用就可以,非常简单、方便、易学。

进入Python交互环境,尝试使用Django提供的数据库访问API。要进入Python的shell,命令行输入:

1
python manage.py shell

在shell中,我们可以做一些测试性、探索性、研究性的操作,但是要注意,这和在脚本中编写代码一样,也有可能会修改数据库中的实际数据。

当你进入shell后,尝试一下下面的API(这些代码必须执行,否则会影响后面的教程操作,我们不必管这些API的具体细节,先熟悉一下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 导入我们写的模型类
In:from polls.models import Question, Choice

# 创建一个新的question对象
# Django推荐使用timezone.now()代替python内置的datetime.datetime.now()
# 这个timezone就来自于Django的依赖库pytz
In:from django.utils import timezone
In:q = Question(question_text="What's new?", pub_date=timezone.now())

# 你必须显式的调用save()方法,才能将对象保存到数据库内
In:q.save()

# 默认情况,你会自动获得一个自增的名为id的主键
In:q.id
Out:1

# 通过python的属性调用方式,访问模型字段的值
In:q.question_text
Out:"What's new?"
In:q.pub_date
Out:datetime.datetime(2012, 2, 26, 13, 0, 0, 775217, tzinfo=<UTC>)

# 通过修改属性来修改字段的值,然后显式的调用save方法进行保存。
In:q.question_text = "What's up?"
In:q.save()

# objects.all() 用于查询数据库内的所有questions
In:Question.objects.all()
Out:<QuerySet [<Question: What's up?>]>

# Django提供了大量的关键字参数查询API
In:Question.objects.filter(id=1)
Out:<QuerySet [<Question: What's up?>]>
In:Question.objects.filter(question_text__startswith='What')
Out:<QuerySet [<Question: What's up?>]>

# 获取今年发布的问卷
In:from django.utils import timezone
In:current_year = timezone.now().year
In:Question.objects.get(pub_date__year=current_year)
Out:<Question: What's up?>

# 查询一个不存在的ID,会弹出异常
In:Question.objects.get(id=2)
Out:Traceback (most recent call last):
...
DoesNotExist: Question matching query does not exist.

# Django为主键查询提供了一个缩写:pk。下面的语句和Question.objects.get(id=1)效果一样.
In:Question.objects.get(pk=1)
Out:<Question: What's up?>

# 看看我们自定义的方法用起来怎么样
In:q = Question.objects.get(pk=1)
In:q.was_published_recently()
Out:True

# 显示所有与q对象有关系的choice集合,目前是空的,还没有任何关联对象。
In:q.choice_set.all()
Out:<QuerySet []>

# 创建3个choices.
In:q.choice_set.create(choice_text='Not much', votes=0)
Out:<Choice: Not much>
In:q.choice_set.create(choice_text='The sky', votes=0)
Out:<Choice: The sky>
In:c = q.choice_set.create(choice_text='Just hacking again', votes=0)

# Choice对象可通过API访问和他们关联的Question对象
In:c.question
Out:<Question: What's up?>

# 同样的,Question对象也可通过API访问关联的Choice对象
In:q.choice_set.all()
Out:<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>
In:q.choice_set.count()
Out:3

# API会自动进行连表操作,通过双下划线分割关系对象。连表操作可以无限多级,一层一层的连接。
# 下面是查询所有的Choices,它所对应的Question的发布日期是今年。(重用了上面的current_year结果)
In:Choice.objects.filter(question__pub_date__year=current_year)
Out:<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>

# 使用delete方法删除对象
In:c = q.choice_set.filter(choice_text__startswith='Just hacking')
In:c.delete()

模型是Django项目的核心,也是动态网站与数据库交互的核心,所以尽量多看理解。

七、admin后台管理站点

  1. 创建管理员

    创建一个可以登录admin站点的用户:

    1
    python manage.py createsuperuser

    按提示输入用户名,邮箱地址,密码确认即可。

  2. 启动服务器

    在浏览器访问http://127.0.0.1:8000/admin/ 你就能看到admin的登陆界面了

  3. 进入站点

    利用刚才建立的admin账户,登陆admin,你将看到admin后台界面

    当前只有两个可编辑的模型:Groups和Users。它们是django.contrib.auth模块提供的身份认证框架内的模型。

  4. 注册投票应用

    应用必须先在admin中进行注册,告诉admin站点,请将polls的模型加入站点内,接受站点的管理

    打开polls/admin.py文件,加入下面的内容:

    1
    2
    3
    4
    from django.contrib import admin
    from .models import Question

    admin.site.register(Question)

    注册完成重启服务器就可以看到Question栏目了

    image-20211015104955227

八、视图和模板

  1. 视图概述:

    Django 中的视图的概念是一类具有相同功能和模板的网页的集合。一个视图通常对应一个页面,提供特定的功能,使用特定的模板。

    在我们的投票应用中,我们将建立下面的视图:

    • 问卷“index”页:显示最新的一些问卷
    • 问卷“detail”页面:显示一个问卷的详细文本内容,没有调查结果但是有一个投票或调查表单。
    • 问卷“results”页面:显示某个问卷的投票或调查结果。
    • 投票动作页面:处理针对某个问卷的某个选项的投票动作。

    在Django中,网页和其它的一些内容都是通过视图来处理的。视图其实就是一个简单的Python函数(在基于类的视图中称为方法)。Django通过对比请求的URL地址来选择对应的视图,也就是路由。为了将 URL 和视图关联起来,Django 使用 URLconfs来配置路由。

  2. 编写视图文件

    打开polls/views.py文件,编写代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    from django.shortcuts import render, get_object_or_404
    from django.http import HttpResponse, HttpResponseRedirect
    from django.urls import reverse
    from .models import Question, Choice


    # Create your views here.
    def index(request):
    latest_question_list = Question.objects.order_by('-pub_date') # 按条件倒序排序
    context = {
    'latest_question_list': latest_question_list
    }
    return render(request, 'polls/index.html', context)


    def detail(request, question_id):
    # 1为模型,2关键字参数,如果对象不存在则弹出Http404错误
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/detail.html', {'question': question})


    def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})


    def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
    selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except(KeyError, Choice.DoesNotExist):
    context = {
    'question': question,
    'error_message': "You didn't select a choice.",
    }
    return render(request, 'polls/detail.html', context)
    else:
    selected_choice.votes += 1
    selected_choice.save()
    return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

    • **render()**函数的第一个位置参数是请求对象(就是view函数的第一个参数),这个参数是固定写法,不需要变动。第二个位置参数是模板文件。还可以有一个可选的第三参数,一个字典,包含需要传递给模板的数据。最后render函数返回一个经过字典数据渲染过的模板封装而成的HttpResponse对象。
    • **get_object_or_404()**方法将一个Django模型作为第一个位置参数,后面可以跟上任意数量的关键字参数,如果对象不存在则弹出Http404错误。
    • request.POST是一个类似字典的对象,允许你通过键名访问提交的数据。request.POST[’choice’]有可能触发一个KeyError异常。
    • HttpResponseRedirect需要一个参数:重定向的URL。这里有一个建议,当你成功处理POST数据后,应当保持一个良好的习惯,始终返回一个HttpResponseRedirect
    • reverse()函数,它能帮助我们避免在视图函数中硬编码URL。它首先需要一个我们在URLconf中指定的name,然后是传递的数据。

    polls/urls.py文件中加入下面的路由,将其映射到我们上面新增的视图

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from django.urls import path
    from . import views

    # 指定该应用的命名空间,此urls只应用于此应用
    app_name = 'polls'

    urlpatterns = [
    path('', views.index, name='index'),
    path('<int:question_id>/', views.detail, name='detail'),
    path('<int:question_id>/results/', views.results, name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
    ]

    • URL names的命名空间 可以在polls/urls.py文件的开头部分,添加一个app_name的变量来指定该应用的命名空间,用来指定此urls.py只属于这个应用。
  3. 使用模板

    polls目录下创建一个新的templates目录,Django会在它里面查找模板文件,在刚才创建的templates目录中,再创建一个新的子目录名叫polls,进入该子目录,创建一个新的HTML文件index.html

    你的模板文件应该是polls/templates/polls/index.html

编辑index.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>投票系统</title>
</head>
<body>
{% if latest_question_list %}
<ul>
{% for question in latest_question_list %}
<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No polls are available.</p>
{% endif %}

</body>
</html>

上面视图中index中的代码会加载polls/index.html文件,并传递给它一个参数。这个参数是一个字典,包含了模板变量名和Python对象之间的映射关系。

在浏览器中通过访问/polls/,你可以看到一个列表,包含“What’s up”的问卷,以及连接到其对应详细内容页面的链接点。

编辑detail.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>投票页</title>
</head>
<body>
<h1>{{ question.question_text }}</h1>

{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% endfor %}
<input type="submit" value="投票">
</form>
</body>
</html>
  • {% for %}循环中的方法调用——question.choice_set.all其实就是Python的代码question.choice_set.all(),它将返回一组可迭代的Choice对象,并用在{% for %}标签中。

  • 为了接收用户的投票选择,我们需要在前端页面显示一个投票界面并创建一个form表单。action表示你要发送的目的url,method表示提交数据的方式,一般分post和get。

  • forloop.counter是Django模板系统专门提供的一个变量,用来表示你当前循环的次数,一般用来给循环项目添加有序数标。

  • 由于我们发送了一个POST请求,就必须考虑一个跨站请求伪造的安全问题,简称CSRF(具体含义请百度)。Django为你提供了一个简单的方法来避免这个困扰,那就是在form表单内添加一条{% csrf_token %}标签,标签名不可更改,固定格式,位置任意,只要是在form表单内。

编辑results.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>投票结果</title>
</head>
<body>
<h1>{{ question.question_text }}</h1>
<u1>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</u1>
<a href="{% url 'polls:detail' question.id %}">Vote again?</a>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>投票结果</title>
</head>
<body>
<h1>{{ question.question_text }}</h1>
<u1>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</u1>
<a href="{% url 'polls:detail' question.id %}">Vote again?</a>
</body>
</html>

现在你可以到浏览器中访问``http://127.0.0.1:8000/polls/1/`了,可以进行投票。投票提交完成你会看到一个结果页面,每投一次,它的内容就更新一次。如果你提交的时候没有选择项目,则会得到一个错误提示。

九、使用通用视图

上面的detail、index和results视图的代码非常相似,有点冗余,他们都具有类似的业务逻辑,实现类似的功能:通过从URL传递过来的参数去数据库查询数据,加载一个模板,利用刚才的数据渲染模板,返回这个模板。Django提供了另一种快捷方式,名为“通用视图”。

将原来的代码改为使用通用视图的方式

  • 修改URLconf设置
  • 删除一些旧的无用的视图
  • 采用基于类视图的新视图

PS:为什么本教程的代码来回改动这么频繁?

通常在写一个Django的app时,我们一开始就要决定使用通用视图还是不用,而不是等到代码写到一半了才重构你的代码成通用视图。但是为了让我们清晰的理解视图的内涵,“故意”走了一条比较曲折的路。

Django的视图类型可以分为函数视图和类视图,也就是FBV和CBV,两者各有优缺点,CBV不一定就高大上。大多数场景下,函数视图更简单易懂,代码量更少。但是在需要继承、封装某些视图的时候,CBV就能发挥优势。

通用视图其实就是Django内置的一些类视图,可以拿来直接使用。但非常简单,只适用于一些简单场景,如果业务逻辑比较复杂,依然需要改造类视图。

1. 修改URLconf

打开polls/urls.py文件,将其修改:

1
2
3
4
5
6
7
8
9
10
11
12
from django.urls import path
from . import views

# 指定该应用的命名空间,此urls只应用于此应用
app_name = 'polls'

urlpatterns = [
path('', views.IndexView.as_view(), name='index'),
path('<int:pk>/', views.DetailView.as_view(), name='detail'),
path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]

2. 修改视图

接下来修改视polls/views.py文件,将原来的index,detail和results视图删除,增加基于类视图的通用视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views import generic

from .models import Question, Choice


# Create your views here.
class IndexView(generic.ListView):
template_name = 'polls/index.html'
context_object_name = 'latest_question_list'

def get_queryset(self):
"""Return the last five published questions."""
return Question.objects.order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
model = Question
template_name = 'polls/detail.html'


class ResultsView(generic.DetailView):
model = Question
template_name = 'polls/results.html'


def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except(KeyError, Choice.DoesNotExist):
context = {
'question': question,
'error_message': "You didn't select a choice.",
}
return render(request, 'polls/detail.html', context)
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

这个视图文件中使用了两种通用视图ListViewDetailView(它们是作为父类被继承的)。ListView:显示一个对象的列表,DetailView:显示特定类型对象的详细页面。

  • 每一种通用视图都需要知道它要作用在哪个模型上,这通过model属性提供。
  • DetailView需要从url捕获到的称为”pk”的主键值,因此我们在url文件中将2和3条目的<question_id>修改成了<pk>

template_name属性用来指定模板名的,用于代替自动生成的默认模板名,指定不同的模板显示不同的页面。

在前面部分的函数视图中我们给模板提供了一个包含questionlatest_question_list的上下文变量。而对于DetailView,question变量会被自动提供,因为我们使用了Django的模型(Question),Django会智能的选择合适的上下文变量。然而,对于ListView,自动生成的上下文变量是question_list。为了覆盖它,使用了context_object_name属性,指定我们希望使用的变量为latest_question_list而不是question_list

类视图是Django比较高级的一种用法,不太好理解,后面慢慢深入理解就好了。

现在可以重新运行开发服务器,然后试试基于类视图的应用程序了,效果和前面的函数视图是一样的。

十、测试

1. 自动化测试

测试是一种例行的、不可缺失的工作,用于检查你的程序是否符合预期。

自动化测试则是系统地较为完整地对程序进行测试,效率高,准确性高,并且大部分共同的测试工作会由系统来帮你完成。一旦你创建了一组自动化测试程序,当你修改了你的应用,你就可以用这组测试程序来检查你的代码是否仍然同预期的那样运行,而无需执行耗时的手动测试。

为什么需要测试?

  • 测试可以节省你的时间
  • 测试不仅仅可以发现问题,还能防止问题
  • 测试使你的代码更受欢迎
  • 测试有助于团队合作

2. 编写测试程序

Django是一个全面、完善、严谨的Web框架,当然不会缺少测试功能。

每个app在创建的时候,都会自动创建一个tests.py文件,就像views.py等文件一样。通常,我们会把测试代码放在应用的tests.py文件中,测试系统将自动地从任何名字以test开头的文件中查找测试程序。

在投票应用的polls/tests.py文件中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Question


class QuestionMethodTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
在将来发布的问卷应该返回False
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIn(future_question.was_published_recently(), False)

我们在这里创建了一个django.test.TestCase的子类,它具有一个方法,该方法创建一个pub_date在未来的Question实例。最后我们检查was_published_recently()的输出,它应该是 False。

3. 运行测试程序

在命令行输入:

1
python manage.py test polls

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTe
sts)
在将来发布的问卷应该返回False
----------------------------------------------------------------------
Traceback (most recent call last):
File "E:\PycharmProjects\Django\mysite\polls\tests.py", line 14, in test_was_publi
shed_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.003s

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

  • python manage.py test polls命令会查找投票应用中所有的测试程序
  • 发现一个django.test.TestCase的子类
  • 为测试创建一个专用的数据库
  • 查找名字以test开头的测试方法
  • test_was_published_recently_with_future_question方法中,创建一个Question实例,该实例的pub_data字段的值是30天后的未来日期。
  • 然后利用assertIs()方法,它发现was_published_recently()返回了True,而不是我们希望的False。

测试程序会通知我们哪个测试失败了,错误出现在哪一行。

4. 修复bug

现在知道bug出现在什么位置,就可以着手去修复了,修改polls/models.py代码:

1
2
3
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now

重新运行测试命令:

1
2
3
4
5
6
7
8
9
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

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

bug已经被修复,程序可以正常运行。

5. 更加全面的测试

事实上,前面的测试用例还不够完整,为了使was_published_recently()方法更加可靠,我们在上面的测试类中再额外添加两个其它的方法,来更加全面地进行测试。

修改polls/tests.py增加代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test_was_published_recently_with_old_question(self):
"""
只要超过1天的问卷,就返回False
"""
time = timezone.now() - datetime.timedelta(days=1, seconds=1)
old_question = Question(pub_date=time)
self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
"""
最近一天内的问卷,返回True
"""
time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
recent_question = Question(pub_date=time)
self.assertIs(recent_question.was_published_recently(), False)

现在我们有三个测试来保证无论发布时间是在过去、现在还是未来Question.was_published_recently()都将返回正确的结果。

十一、静态文件

Web应用除了由服务器生成的HTML文件外,一般需要提供一些其它的必要文件,比如图片文件、JavaScript脚本和CSS样式表等等,用来为用户呈现出一个完整的网页。在Django中,我们将这些文件统称为“静态文件”,因为这些文件的内容基本是固定不变的,不需要动态生成,只需要放在指定位置模板中引用即可。

1. 使用静态文件

首先在你的polls目录中创建一个static目录。Django将在那里查找静态文件,这与Django在polls/templates/中寻找对应的模板文件的方式是一致的。

在刚才的static目录中新建一个polls子目录,再在该子目录中创建一个css目录,在css目录中创建一个style.css文件。这个css样式文件对应的url为polls/static/polls/style.css。你可以通过书写polls/style.css在Django中访问这个静态文件,与你如何访问模板的路径类似。

建议:1.在static/目录下创建与应用同名目录;2.在static/polls/目录下创建出css、js、images等各类区分开的静态文件目录,结构更规整。

将下面的代码写入样式文件polls/static/polls/css/style.css

1
2
3
li a {
color: green;
}

接下来在模板文件polls/templates/polls/index.html的头部加入下面的代码:

1
2
3
4
{% load static %}	# 放在页面第一行

# 放在head标签中
<link rel="stylesheet" href="{% static 'polls/css/style.css' %}" type="text/css">

{% static %}模板标签会生成静态文件的绝对URL路径。

重启服务器查看页面,发现页面上的链接字体应用了你写的css样式了。

2. 添加背景图片

按照上面一样,我们在polls/static/polls/目录下创建一个用于存放图片的images子目录,在这个子目录里放入background.jpg文件。

在css样式文件polls/static/polls/css/style.css中添加下面的代码:

1
2
3
body {
background: white url("../images/background.jpg") no-repeat;
}

重新加载http://localhost:8000/polls/(CTRL+F5),你在页面会看到载入的背景图片。

{% static %}模板标签不能用在静态文件,比如样式表中,因为他们不是由Django生成的。 你应该使用相对路径来相互链接静态文件,因为这样你可以改变STATIC_URL( static模板标签用它来生成URLs)而不用同时修改一大堆静态文件中路径相关的部分。

3. 直接访问静态文件

实际上不管是在Django开发服务器上,还是在nginx+uwsgi+django部署的服务器上,都可以直接通过url访问静态文件,不需要在Django中专门为每个静态文件编写url路由和视图。

十二、自定义Admin

1. 自定义后台表单

在前面的学习过程中,通过admin.site.register(Question)语句,我们在admin站点中注册了Question模型。Django会自动生成一个该模型的默认表单页面。如果你想自定义该页面的外观和工作方式,可以在注册对象的时候告诉Django你的自定义选项。

下面是一个修改admin表单默认排序方式的例子。修改polls/admin.py的代码:

1
2
3
4
5
6
7
8
9
10
11
from django.contrib import admin
from .models import Question


# Register your models here.
class QuestionAdmin(admin.ModelAdmin):
fields = ['pub_date', 'question_text']


# 将app模型加入admin站点管理
admin.site.register(Question, QuestionAdmin)

只需要创建一个继承admin.ModelAdmin的模型管理类,在其中进行一些自定义操作,然后将它作为第二个参数传递给admin.site.register(),第一个参数则是Question模型本身。

上面的修改让Date Publication字段显示在Question字段前面了(默认是在后面)

如果有很多的字段,选择一种直观的符合我们人类习惯的排序方式则非常有用。但当表单含有大量字段的时候,你更多的是想将表单划分为一些字段的集合。

再次修改polls/admin.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from django.contrib import admin
from .models import Question


# Register your models here.
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
(None, {'fields': ['question_text']}),
('Date information', {'fields': ['pub_date']}),
]


# 将app模型加入admin站点管理
admin.site.register(Question, QuestionAdmin)

字段集合fieldsets中每一个元组的第一个元素是该字段集合的标题。

如下图:

image-20211015192358679

2. 添加关联对象

虽然我们已经有了Question的管理页面,但是一个Question有多个Choices,如果想显示Choices的内容怎么办?有两个办法可以解决这个问题。

第一个是像Question一样将Choice注册到admin站点,这很容易,修改polls/admin.py,增加下面的内容:

1
2
3
from .models import Choice, Question

admin.site.register(Choice)

重启服务器,再次访问admin页面,就可以看到Choice条目了。

点击它右边的增加按钮,进入“Add Choice”表单页面,如下图:

image-20211015193422116

在这个表单中,Question字段是一个select选择框,包含了当前数据库中所有的Question实例。Django在admin站点中,自动地将所有的外键关系展示为一个select框。在我们的例子中,目前只有一个question对象存在。

请注意图中的绿色加号,它连接到Question模型。每一个包含外键关系的对象都会有这个绿色加号。点击它,会弹出一个新增Question的表单,类似Question自己的添加表单。填入相关信息点击保存后,Django自动将该Question保存在数据库,并作为当前Choice的关联外键对象。白话讲就是,新建一个Question并作为当前Choice的外键。

第二种方法在创建Question对象的时候就可以直接添加一些Choice,删除polls/admin.py中Choice模型对register()方法的调用。然后,编辑Question的内容,最后整个文件的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from django.contrib import admin
from .models import Question, Choice


# Register your models here.
class ChoiceInline(admin.StackedInline):
model = Choice
extra = 3


class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
(None, {'fields': ['question_text']}),
('Date information', {'fields': ['pub_date']}),
]
inlines = [ChoiceInline]


# 将app模型加入admin站点管理
admin.site.register(Question, QuestionAdmin)

上面的代码相当于告诉Django,Choice对象将在Question管理页面进行编辑,默认情况,请提供3个Choice对象的编辑区域。

重启服务器,进入“Add question”页面,应该看到如下图所示:

image-20211015194854635

在3个插槽的最后,还有一个添加另一个 Choice链接。点击它,又可以获得一个新的插槽。如果你想删除插槽,点击它最右边的灰色X图标即可。

这里还有点小问题。上面页面中插槽纵队排列的方式需要占据大块的页面空间,查看起来很不方便。为此,Django提供了一种扁平化的显示方式,你仅仅只需要修改一下ChoiceInline继承的类为admin.TabularInline替代先前的StackedInline类(其实,从类名上你就能看出两种父类的区别)。

1
2
class ChoiceInline(admin.TabularInline):
# ...

重新刷新页面就可以看到如下图的排列方式:

image-20211015195515894

注意“删除”列,它可以删除那些已有的Choice和新建的Choice。

3. 定制实例的列表页面

Question的添加和修改页面我们已经自定义得差不多了,下面让我们来装饰一下“实例列表”(change list)页面,该页面显示了当前系统中所有的questions实例。

该页面默认看起来是这样的:

image-20211015195916690

通常,Django只显示__str()__方法指定的内容。但是很多时候,我们可能要同时显示一些别的内容。要实现这一目的,可以使用list_display属性,它是一个由字段组成的元组,其中的每一个字段都会按顺序显示在页面上,在polls/admin.py代码中添加:

1
2
3
class QuestionAdmin(admin.ModelAdmin):
# ...上面默认...
list_display = ('question_text', 'pub_date', 'was_published_recently')

我们把was_published_recently()方法的结果也显示出来。如下图:

image-20211015200313966

你可以点击每一列的标题,来根据这列的内容进行排序。但是was_published_recently这一列除外,不支持这种根据函数输出结果进行排序的方式。同时请注意,was_published_recently这一列的列标题默认是方法的名字,内容则是输出的字符串表示形式。

可以通过给方法提供一些属性来改进输出的样式,如下面所示。注意这次修改的是polls/models.py文件,不要搞错了!主要是增加了最后面三行内容:

1
2
3
4
5
6
7
8
9
class Question(models.Model):
# ...
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now

was_published_recently.admin_order_field = 'pub_date'
was_published_recently.boolean = True
was_published_recently.short_description = 'Published recently?'

重启服务器刷新页面查看效果:

image-20211016093810840

我们还可以使用list_filter属性,对显示结果进行过滤!

polls/admin.py的QuestionAdmin中添加下面的代码:

1
list_filter = ['pub_date']

再次刷新change list页面,你会看到在页面右边多出了一个基于pub_date的过滤面板,如下图所示:

image-20211016094011066

根据你选择的过滤条件的不同,Django会在面板中添加不同的过滤选项。由于pub_date是一个DateTimeField,因此Django自动添加了这些日期选项。

还可以再添加一些搜索功能,同样在QuestionAdmin中添加下面的代码:

1
search_fields = ['question_text']

刷新页面查看效果,增加了搜索框:

image-20211016094258278

当输入搜索关键字后,Django会在question_text字段内进行搜索。只要你愿意,你可以使用任意多个搜索字段,Django在后台使用的都是SQL查询语句的LIKE语法,但是有限制的搜索字段有助于后台的数据库查询效率。

这个页面还自动提供分页功能,默认每页显示100条,只是我们的实例只有一个,所以不显示分页链接。

4. 定制Admin整体界面

在每一个项目的admin页面顶端都显示Django管理是很单调的,它仅仅是个占位文本。利用Django的模板系统,我们可以快速修改它

manage.py文件同级下有一个templates目录,如果没有则自行创建。然后,打开设置文件mysite/settings.py,在TEMPLATES条目中有一个DIRS选项,同理没有的话自行添加代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'] # 添加此行
,
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

DIRS是一个文件系统目录的列表,是模板的搜索路径。当加载Django模板时,会在DIRS中进行查找。这里面的目录往往都是全局性的,区别于app自己内部的templates目录。

在templates目录中再创建一个admin目录,将admin/base_site.html模板文件拷贝到该目录内。这个HTML文件来自Django源码,它位于django/contrib/admin/templates目录内。(我的本机上时D:\python37\Lib\site-packages\django\contrib\admin\templates\admin)可供参考。事实上,如果你用的是Pycharm建立的虚拟环境,那么直接去venv目录中寻找即可。

提示:如果你无法找到Django源代码文件的存放位置,可以使用下面的命令

1
python -c "import django; print(django.__path__)"

更改编辑base_site.html文件,用你喜欢的站点名字替换掉{{ site_header|default:_(’Django administration’) }}(包括两个大括号一起替换掉),代码如下:

1
2
3
4
5
6
7
8
9
10
{% extends "admin/base.html" %}

{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">投票后台管理</a></h1>
{% endblock %}

{% block nav-global %}{% endblock %}

在这里,我们使用的是硬编码,强行改名为”投票后台管理”。但是在实际的项目中,你可以使用django.contrib.admin.AdminSite.site_header属性,方便的对这个页面title进行自定义。

具体操作如下,恢复base_site.html文件,不需要替换掉{{ site_header|default:_(’Django administration’) }},只需要在polls/admin.py中添加以下代码:

1
admin.AdminSite.site_header = '投票后台管理'

admin全部具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from django.contrib import admin
from .models import Question, Choice


# Register your models here.
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3


class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
(None, {'fields': ['question_text']}),
('Date information', {'fields': ['pub_date']}),
]
inlines = [ChoiceInline]
list_display = ('question_text', 'pub_date', 'was_published_recently')
list_filter = ['pub_date']
search_fields = ['question_text']


# 将app模型加入admin站点管理
admin.site.register(Question, QuestionAdmin)
admin.AdminSite.site_header = '投票后台管理'

刷新页面查看,会发现效果跟上面硬编码同样。

image-20211016101445897

提示

  • 所有Django默认的admin模板都可以被重写,类似刚才重写base_site.html模板的方法一样,从源代码目录将HTML文件拷贝至你自定义的目录内,然后修改文件,或者一些修改可使用修改属性方法。

  • 直接修改Django源码不是好的做法,所以我们不直接修改base_site.html模板,我们复制了一份模板,在其中修改了站点名字,为了让修改的模板能够自动替换原来的模板,我们创建了一个templates目录。

  • 这个新建的template目录之所以能起作用,是因为我们在settings中配置了一个DIRS。

十三、定制admin首页

默认情况下admin页面显示所有INSTALLED_APPS内并在admin应用中注册过的app,以字母顺序进行排序。

要定制admin首页,你需要重写admin/index.html模板,就像前面修改base_site.html模板的方法一样,从源码目录拷贝到你指定的目录内。编辑该文件,你会看到文件内使用了一个app_list模板变量。该变量包含了所有已经安装的Django应用。你可以硬编码链接到指定对象的admin页面,使用任何你认为好的方法,用于替代这个app_list

十四、总结

进行到这里,整个基本的投票Web应用就创建完成了,内部详细的细节可以自行学习完善,例如前端页面美观等,整个投票项目mysite,在Pycharm中的文件组织结构如下图所示,可以对比一下是否有不同。

image-20211016103521291

接下来可以继续深入学习,做出更高级的应用。