djangoproject/first_django_app.md

693 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# First Django App
Target: create a basic poll application, with 2 parts a **public site** and a **admin site**
* **public site**: let's pp view polls and vote
* **admin site**: let's you(admin) add/change/delete poll
## Part 1
1. Create web project
2. Create 1st app
3. Create 1st view
4. Add view & app into web url
### Creating a project
In the source directory, create project by
```
$ django-admin startproject <project_name>
```
Constraint on `<project_name>`:
* don't use names that conflict with django
* e.g. `django`, `test` (test with built-in test file)
```
mysite/
manage.py
mysite/
__init__.py
settings.py
urls.py
asgi.py
wsgi.py
```
Explain file structure:
* top-level `mysite/`: container of the project, can be renamed latter w/o affect Django framework
* `manage.py`: cmd-line utility let us to interact (admin) with Django projects.
* inner `mysite/` dir: actual Python package for the project.
* Importing any package from here: e.g. `mysite.urls`
* `mysite/__init__.py`: empty file, tell python start from here.
* `mysite/settings.py`: settings/config for this django project.
* `mysite/urls.py`: URL declarations for this Django pjt; a "table of contents" of this Django website.
* `mysite/asgi.py`: An entry-point for ASGI-compatible webservers
* `mysite/wsgi.py`: Entry-point for WSGI-compatible webservers
### The development server
Start server steps:
1. cd into outer `mysite/` dir
2. Start server via: `python manage.py runserver`
Outcome:
* A development server is started. So no need to config a production server (e.g. Apache)
* Note: don't use it as production environment.
### Creating the Polls app
> Projects vs. apps
>
> Whats the difference between a project and an app? An app is a Web application that does something e.g., a Weblog system, a database of public records or a small poll app. A project is a collection of configuration and apps for a particular website. A project can contain multiple apps. An app can be in multiple projects.
Create a poll app along `mysite/`. For what?
```
$ python manage.py startapp polls
```
app hierarchy
```
polls/
__init__.py
admin.py
apps.py
migrations/
__init__.py
models.py
tests.py
views.py
```
### Write the first view
#### Step 1: Create a simple view of `polls` app
modify `polls/views.py` as
```python
from django.http import request
from django.shortcuts import render
def index(request):
return HttpResponse("Hello, world. You're at the polls index")
```
* `def index(request):...` is the simplest view
#### Step 2: In `polls` app, map the URL to this view
After creating a view, map it to a URL so we can call it. Create `polls/urls.py`, and setup mapping `urlpatterns`
```python
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
]
```
#### Step 3: Point the root URLconf for polls
To point the root URLconf at `polls.urls` module:
1. In `mysite/urls.py`
1. add import for `django.urls.inclue`
2. insert a `include()` in `urlpatterns` list
i.e.
```python
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('polls/', include('polls.urls')),
path('admin/', admin.site.urls),
]
```
##### django.urls.include()
> django.urls.include(): A function that takes a full Python import path to another URLconf module that should be “included” in this place.
* new imported `include()` function allows referencing other URLconfs.
* `'polls.urls'` is the package that we are using, hence `mysite` can connect to `polls` app
##### path()
Here `path()` function is passed **route** and **view**; two additional option available **kwargs**, and **name**
* `path()` argument **route**: a string that contains a URL pattern. When Django processing a request, it starts at the first pattern in `urlpatterns` and go down the list, comparing requested URL against each pattern
* `path()` argument **view**: when Django finds a matching pattern, it calls specified view function (a `HttpRequest` obj as the 1st argument, and others captured values as keyword argument)
* `path()` argument **kwargs**: passed in a dictionary to the target view
* `path()` argument **name**: naming URL, so we can refer to it elsewhere.
## Part 2
### Database setup
1. Modify `mysite/settings.py` for database binding
2. `python manage.py migrate` create database for each app
### Models Creation
在 Django 里写一个数据库驱动的 Web 应用的第一步: **定义模型** - 也就是数据库结构设计和附加的其它元数据 Meta Data
### Activate Models
改变模型需要这三步:
* 编辑 models.py 文件,改变模型
* 运行 python manage.py makemigrations 为模型的改变生成迁移文件。
* 运行 python manage.py migrate 来应用数据库迁移。
在这之前确认polls已经在 INSTALLED_APPS的settings中。
详情如何使用 `manage.py` 可check [Django后台文档](https://docs.djangoproject.com/zh-hans/3.1/ref/django-admin/)
### Test API
进入 `python manage.py shell` 可以使用Django创建的各种API如[数据库抽象API database API(建议细看)](https://docs.djangoproject.com/zh-hans/3.1/topics/db/queries/)
## Part 3
* In Django, web content & HTML come from Views models. Every view is a Python function/class. Django use customer requested URL to decide which View to generate.
* Django use 'URLconfs' to map btw URL and Views. Details of[pURL manager](https://docs.djangoproject.com/zh-hans/3.1/topics/http/urls/)
* URL's general form: `/newsarchive/<year>/<month>/`
### 3.1 Create more Views
> 当某人请求你网站的某一页面时——比如说, "/polls/34/" Django 将会载入 mysite.urls 模块,因为这在配置项 ROOT_URLCONF 中设置了。然后 Django 寻找名为 urlpatterns 变量并且按序匹配正则表达式。在找到匹配项 'polls/',它切掉了匹配的文本("polls/"),将剩余文本——"34/",发送至 'polls.urls' URLconf 做进一步处理。在这里剩余文本匹配了 "<int:question_id>/",使得我们 Django 以如下形式调用 polls.urls.detail():
> `detail(request=<HttpRequest object>, question_id=34)`
### 3.2 Create a true useful View
每个视图必须要做的只有两件事:返回一个包含被请求页面内容的 HttpResponse 对象,或者抛出一个异常,比如 Http404 。至于你还想干些什么,随便你。
你的视图可以从数据库里读取记录,可以使用一个模板引擎(比如 Django 自带的,或者其他第三方的),可以生成一个 PDF 文件,可以输出一个 XML创建一个 ZIP 文件,你可以做任何你想做的事,使用任何你想用的 Python 库。
`django.shortcuts.render` 可以“载入模板,填充上下文,再返回由它生成的 HttpResponse 对象“
#### shortcut function: `render()`
「载入模板,填充上下文,再返回由它生成的 HttpResponse 对象」是一个非常常用的操作流程。于是 Django 提供了一个快捷函数 render
> The render() function takes the request object as its first argument, a template name as its second argument and a dictionary as its optional third argument. It returns an HttpResponse object of the given template rendered with the given context.
### 3.3 Throw 404 error
如果指定问题 ID 所对应的问题不存在,这个视图就会抛出一个 Http404 异常。
尝试用 get() 函数获取一个对象,如果不存在就抛出 Http404 错误也是一个普遍的流程。Django 也提供了一个快捷函数, 从而取代try/except语句
> 什么我们使用辅助函数 get_object_or_404() 而不是自己捕获 ObjectDoesNotExist 异常呢?还有,为什么模型 API 不直接抛出 ObjectDoesNotExist 而是抛出 Http404 呢?
> 因为这样做会增加模型层和视图层的耦合性。指导 Django 设计的最重要的思想之一就是要保证松散耦合。一些受控的耦合将会被包含在 django.shortcuts 模块中。
### 3.4 Using template system
[Templates in Django](https://docs.djangoproject.com/en/3.1/topics/templates/)
`polls/detail.html` 模板里正式的代码:
```html
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>
```
模板系统统一使用点符号来访问变量的属性
### 3.5 Remove hardlink URL in template
In template `polls/index.html`, links are hardcoded as shown
```html
{% for question in latest_question_list %}
<li><a href="/polls/{{ question.id }}/">{{ question.question_text }}</a></li>
{% endfor %}
```
Consider `index.html` will be reused numerous times, 去耦合is necessary.
```html
<li><a href="{% url 'detail' question.id %}">{{ question.question_text }}</a></li>
```
### 3.6 为 URL 名称添加命名空间
在根 URLconf 中添加命名空间 (namespace)
1. Add `app_name = 'polls'` for namespace in `poll.urls.py`
2. Change `detail` in `index.html` to `polls:detail`
## Part 4. Form
### 4.1 编写一个简单的表单
Edit `polls/templates/polls/detail.html` so it contain **`HTML<form>`** elements:
```html
<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="Vote">
</form>
```
Explain:
* 当有人选择一个单选按钮并提交表单提交时,它将发送一个 POST 数据 choice=# ,其中# 为选择的 Choice 的 ID。这是 HTML 表单的基本概念。
* 我们设置表单的 action 为 {% url 'polls:vote' question.id %} ,并设置 method="post" 。使用 method="post"``(与其相对的是 ``method="get"`)是非常重要的,因为这个提交表单的行为会改变服务器端的数据。 **无论何时,当你需要创建一个改变服务器端数据的表单时,请使用 ``method="post"** 。这不是 Django 的特定技巧;这是优秀的网站开发技巧。
* 所有针对内部 URL 的 POST 表单都应该使用 {% csrf_token %} 模板标签。
修改 `view.py`, 将Choice 逻辑灌输入其中。
```python
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
# request.POST 是一个类字典对象 (dictionary-like object),让你可以通过关键字的名字获取提交的数据。
# request.POST['choice'] 以字符串形式返回选择的 Choice 的 ID
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
# 如果在 request.POST['choice'] 数据中没有提供 choice POST 将引发一个 KeyError 。上面的代码检查 KeyError ,如果没有给出 choice 将重新显示 Question 表单和一个错误信息。
# Redisplay the question voting form.
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "You didn't select a choice.",
})
else:
selected_choice.votes += 1
selected_choice.save()
# 在增加 Choice 的得票数之后,代码返回一个 HttpResponseRedirect 而不是常用的 HttpResponse 、 HttpResponseRedirect 只接收一个参数:用户将要被重定向的 URL
# Always return an HttpResponseRedirect after successfully dealing
# with POST data. This prevents data from being posted twice if a
# user hits the Back button.
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))
# 我们在 HttpResponseRedirect 的构造函数中使用 reverse() 函数。这个函数避免了我们在视图函数中硬编码 URL。它需要我们给出我们想要跳转的视图的名字和该视图所对应的 URL 模式中需要给该视图提供的参数。在本例中,使用在 教程第 3 部分 中设定的 URLconf reverse() 调用将返回一个这样的字符串:'/polls/3/results/' 其中 3 是 question.id 的值。重定向的 URL 将调用 'results' 视图来显示最终的页面。
```
当有人对 Question 进行投票后, vote() 视图将请求重定向到 Question 的结果界面 (result view), edit it:
```python
def results(request, question_id):
question = get_object_or_404(Question, pk=question_id)
return render(request, 'polls/results.html', {'question': question})
```
同时,我们需要创建一个`polls/results.html` template
```python
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</ul>
<a href="{% url 'polls:detail' question.id %}">Vote again?</a>
```
### 4.2 使用通用视图(generic views):代码还是少点好
Web 开发中的一个常见情况:根据 URL 中的参数从数据库中获取数据、载入模板文件然后返回渲染后的模板。 由于这种情况特别常见Django 提供一种快捷方式,叫做“通用视图”系统。
让我们将我们的投票应用转换成使用通用视图系统,这样我们可以删除许多我们的代码。我们仅仅需要做以下几步来完成转换,我们将:
* 转换 URLconf。
* 删除一些旧的、不再需要的视图。
* 基于 Django 的通用视图引入新的视图。
#### Modify URLconf of polls
Change
```python
# ex: /polls/
path('', views.index, name='index'),
# ex: /polls/5/
path('<int:question_id>/', views.detail, name='detail'),
# ex: /polls/5/results/
path('<int:question_id>/results/', views.results, name='results'),
```
to
```python
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'),
```
* 注意,第二个和第三个匹配准则中,路径字符串中匹配模式的名称已经由 <question_id> 改为 <pk> (primary key)。
#### Modify Views
Target: Replace `index`, `details` & `results` views using Django's generic views that named in `polls/urls.py`
```python
def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
context = {'latest_question_list': latest_question_list}
return render(request, 'polls/index.html', context)
def detail(request, question_id):
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})
```
改为
```python
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'
```
两个通用视图: ListView 和 DetailView 。这两个视图分别抽象“显示一个对象列表”和“显示一个特定类型对象的详细信息页面”这两种概念。
* 每个通用视图需要知道它将作用于哪个模型。 这由 model 属性 (i.e. model members) 提供。
* DetailView 期望从 URL 中捕获名为 "pk" 的主键值,所以我们为通用视图把 question_id 改成 pk 。
解释 DetailView
* 默认情况下,通用视图 DetailView 使用一个叫做 <app name>/<model name>_detail.html 的模板。
* e.g. "polls/question_detail.html" template for us.
* 但是我们可以用 model member `template_name` 来指定模板
解释 ListView
* ListView 也使用一个叫做 <app name>/<model name>_list.html 的默认模板
* 而这里我们使用 `template_name` 来指定模板
* 我们提供 context_object_name 属性,表示我们想使用 latest_question_list
重启服务器后就是新的投票应用同样的html但是内部用了generic view
## Part 5 Automated Test
TDD with python. Always stick to it.
### 5.1 First test
#### Create/Verify a bug
幸运的是,我们的 polls 应用现在就有一个小 bug 需要被修复:我们的要求是如果 Question 是在一天之内发布的, Question.was_published_recently() 方法将会返回 True ,然而现在这个方法在 Question 的 pub_date 字段比当前时间还晚时也会返回 True这是个 Bug
确认bug:
```
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True
```
#### Create a test to verify this bug
All tests (polls/tests.py) are in app directory
```python
import datetime
from django.test import TestCase
from django.utils import timezone
from .models import Question
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
was_published_recently() returns False for questions whose pub_date is in future
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
```
Result
```
python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
was_published_recently() returns False for questions whose pub_date is in future
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jason/HomeWorkstation/SynologyGiteaSpace/djangoproject/src/polls/tests.py", line 17, in test_was_published_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
```
解释上述msg:
* python manage.py test polls 将会寻找 polls 应用里的测试代码
* 它找到了 django.test.TestCase 的一个子类
* 它创建一个特殊的数据库供测试使用
* 它在类中寻找测试方法——以 test 开头的方法。
* 在 test_was_published_recently_with_future_question 方法中,它创建了一个 pub_date 值为 30 天后的 Question 实例。
* 接着使用 assertls() 方法,发现 was_published_recently() 返回了 True而我们期望它返回 False。
#### Fix the bug
Fix bug in `polls/models.py`
```python
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
```
#### Comprehensive Tests
Write more tests to make sure it's comprehensive
```python
def test_was_published_recently_with_old_question(self):
"""
was_published_recently() returns False for questions whose pub_date is older than 1 day.
"""
time = timezone.now() - datetime.timedelta(days=1, seconds=1)
old_quesion = Question(pub_date=time)
self.assertIs(old_quesion.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
was_published_recently() returns True for questions whose pub_date is within the last day.
"""
time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
recent_question = Question(pub_date=time)
self.assertIs(recent_question.was_published_recently(), True)
```
### 5.2 Test a view
Target: 如果 pub_date 设置为未来某天,这应该被解释为这个问题将在所填写的时间点才被发布,而在之前是不可见的。
#### A test for a view
We use TDD (i.e. test first, then develop)
#### The Django test client
Django provides a test **Client** (a class) to simulate a user interacting with the code at the view level.
We can try it in interactive shell following the guide
#### Test and Improve our view
Add following in `tests.py`
```python
def create_question(question_text, days):
"""
Create a question with the given `question_text` and published the
given number of `days` offset to now (negative for questions published
in the past, positive for questions that have yet to be published).
"""
time = timezone.now() + datetime.timedelta(days=days)
return Question.objects.create(question_text=question_text, pub_date=time)
class QuestionIndexViewTests(TestCase):
def test_no_questions(self):
"""
If no questions exist, an appropriate message is displayed.
"""
response = self.client.get(reverse('polls:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_past_question(self):
"""
Questions with a pub_date in the past are displayed on the
index page.
"""
create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_future_question(self):
"""
Questions with a pub_date in the future aren't displayed on
the index page.
"""
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_future_question_and_past_question(self):
"""
Even if both past and future questions exist, only past questions
are displayed.
"""
create_question(question_text="Past question.", days=-30)
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_two_past_questions(self):
"""
The questions index page may display multiple questions.
"""
create_question(question_text="Past question 1.", days=-30)
create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question 2.>', '<Question: Past question 1.>']
)
```
Testing generated
```
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
FF......
======================================================================
FAIL: test_future_question (polls.tests.QuestionIndexViewTests)
Questions with a pub_date in the future aren't displayed on
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jason/HomeWorkstation/SynologyGiteaSpace/djangoproject/src/polls/tests.py", line 77, in test_future_question
self.assertContains(response, "No polls are available.")
File "/home/jason/miniconda3/envs/django/lib/python3.8/site-packages/django/test/testcases.py", line 470, in assertContains
self.assertTrue(real_count != 0, msg_prefix + "Couldn't find %s in response" % text_repr)
AssertionError: False is not true : Couldn't find 'No polls are available.' in response
======================================================================
FAIL: test_future_question_and_past_question (polls.tests.QuestionIndexViewTests)
Even if both past and future questions exist, only past questions
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/jason/HomeWorkstation/SynologyGiteaSpace/djangoproject/src/polls/tests.py", line 88, in test_future_question_and_past_question
self.assertQuerysetEqual(
File "/home/jason/miniconda3/envs/django/lib/python3.8/site-packages/django/test/testcases.py", line 1052, in assertQuerysetEqual
return self.assertEqual(list(items), values, msg=msg)
AssertionError: Lists differ: ['<Question: Future question.>', '<Question: Past question.>'] != ['<Question: Past question.>']
First differing element 0:
'<Question: Future question.>'
'<Question: Past question.>'
First list contains 1 additional elements.
First extra element 1:
'<Question: Past question.>'
- ['<Question: Future question.>', '<Question: Past question.>']
+ ['<Question: Past question.>']
----------------------------------------------------------------------
Ran 8 tests in 0.018s
FAILED (failures=2)
Destroying test database for alias 'default'...
```
Fix it by change `get_queryset()` method in Question. 让他它能通过将 Question 的 pub_data 属性与 timezone.now() 相比较来判断是否应该显示此 Question
#### Testing the DetailView
Target: hide future questions even if user type correct URL
Add test cases first
```python
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
"""
The detail view of a question with a pub_date in the future returns a 404 not found.
"""
future_question = create_question(question_text="Future question.", days=5)
url = reverse('poll:detail', args=(future_question.id, ))
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_past_questioin(self):
"""
The detail view of a question with a pub_date in the past displays the question's text.
"""
past_question = create_question(question_text='Past Question.', days=-5)
url = reverse('polls:detail', args=(past_question.id,))
response = self.client.get(url)
self.assertContains(response, past_question.question_text)
```