djangoproject/first_django_app.md

26 KiB
Raw Blame History

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

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

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.

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后台文档

Test API

进入 python manage.py shell 可以使用Django创建的各种API数据库抽象API database API(建议细看)

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 ofpURL manager
  • 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

polls/detail.html 模板里正式的代码:

<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>

模板系统统一使用点符号来访问变量的属性

In template polls/index.html, links are hardcoded as shown

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

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

<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 逻辑灌输入其中。

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:

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

<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

    # 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

    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> 改为 (primary key)。

Modify Views

Target: Replace index, details & results views using Django's generic views that named in polls/urls.py

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

改为

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 使用一个叫做 /_detail.html 的模板。
    • e.g. "polls/question_detail.html" template for us.
  • 但是我们可以用 model member template_name 来指定模板

解释 ListView

  • ListView 也使用一个叫做 /_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

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

    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

    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

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

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)