第 19 章 用户账户

第 19 章 用户账户 - 图1

Web应用程序的核心是让任何用户都能够注册账户并能够使用它,不管用户身处何方。在本章中,你将创建一些表单,让用户能够添加主题和条目,以及编辑既有的条目。你还将学习Django如何防范对基于表单的网页发起的常见攻击,这让你无需花太多时间考虑确保应用程序安全的问题。

然后,我们将实现一个用户身份验证系统。你将创建一个注册页面,供用户创建账户,并让有些页面只能供已登录的用户访问。接下来,我们将修改一些视图函数,使得用户只能看到自己的数据。你将学习如何确保用户数据的安全。

19.1 让用户能够输入数据

建立用于创建用户账户的身份验证系统之前,我们先来添加几个页面,让用户能够输入数据。我们将让用户能够添加新主题、添加新条目以及编辑既有条目。

当前,只有超级用户能够通过管理网站输入数据。我们不想让用户与管理网站交互,因此我们将使用Django的表单创建工具来创建让用户能够输入数据的页面。

19.1.1 添加新主题

首先来让用户能够添加新主题。创建基于表单的页面的方法几乎与前面创建网页一样:定义一个URL,编写一个视图函数并编写一个模板。一个主要差别是,需要导入包含表单的模块forms.py。

1. 用于添加主题的表单

让用户输入并提交信息的页面都是表单,哪怕它看起来不像表单。用户输入信息时,我们需要进行验证,确认提供的信息是正确的数据类型,且不是恶意的信息,如中断服务器的代码。然后,我们再对这些有效信息进行处理,并将其保存到数据库的合适地方。这些工作很多都是由Django自动完成的。

在Django中,创建表单的最简单方式是使用ModelForm,它根据我们在第18章定义的模型中的信息自动创建表单。创建一个名为forms.py的文件,将其存储到models.py所在的目录中,并在其中编写你的第一个表单:

forms.py

  1. from django import forms
  2. from .models import Topic
  3. class TopicForm(forms.ModelForm):❶
  4. class Meta:
  5. model = Topic
  6. fields = ['text']❸
  7. labels = {'text': ''}❹

我们首先导入了模块forms以及要使用的模型Topic。在❶处,我们定义了一个名为TopicForm的类,它继承了forms.ModelForm

最简单的ModelForm版本只包含一个内嵌的Meta类,它告诉Django根据哪个模型创建表单,以及在表单中包含哪些字段。在❷处,我们根据模型Topic创建一个表单,该表单只包含字段text(见❸)。❹处的代码让Django不要为字段text生成标签。

2. URL模式new_topic

这个新网页的URL应简短而具有描述性,因此当用户要添加新主题时,我们将切换到http://localhost:8000/new_topic/。下面是网页new_topic的URL模式,我们将其添加到learning_logs/urls.py中:

urls.py

  1. --snip--
  2. urlpatterns = [
  3. --snip--
  4. # 用于添加新主题的网页
  5. url(r'^new_topic/$', views.new_topic, name='new_topic'),
  6. ]

这个URL模式将请求交给视图函数new_topic(),接下来我们将编写这个函数。

3. 视图函数new_topic()

函数new_topic()需要处理两种情形:刚进入new_topic网页(在这种情况下,它应显示一个空表单);对提交的表单数据进行处理,并将用户重定向到网页topics

views.py

  1. from django.shortcuts import render
  2. from django.http import HttpResponseRedirect
  3. from django.core.urlresolvers import reverse
  4. from .models import Topic
  5. from .forms import TopicForm
  6. --snip--
  7. def new_topic(request):
  8. """添加新主题"""
  9. if request.method != 'POST':
  10. # 未提交数据:创建一个新表单
  11. form = TopicForm()
  12. else:
  13. # POST提交的数据,对数据进行处理
  14. form = TopicForm(request.POST)
  15. if form.is_valid():
  16. form.save()
  17. return HttpResponseRedirect(reverse('learning_logs:topics'))
  18. context = {'form': form}
  19. return render(request, 'learning_logs/new_topic.html', context)

我们导入了HttpResponseRedirect类,用户提交主题后我们将使用这个类将用户重定向到网页topics。函数reverse()根据指定的URL模型确定URL,这意味着Django将在页面被请求时生成URL。我们还导入了刚才创建的表单TopicForm

4. GET请求和POST请求

创建Web应用程序时,将用到的两种主要请求类型是GET请求和POST请求。对于只是从服务器读取数据的页面,使用GET请求;在用户需要通过表单提交信息时,通常使用POST请求。处理所有表单时,我们都将指定使用POST方法。还有一些其他类型的请求,但这个项目没有使用。

函数new_topic()将请求对象作为参数。用户初次请求该网页时,其浏览器将发送GET请求;用户填写并提交表单时,其浏览器将发送POST请求。根据请求的类型,我们可以确定用户请求的是空表单(GET请求)还是要求对填写好的表单进行处理(POST请求)。

❶处的测试确定请求方法是GET还是POST。如果请求方法不是POST,请求就可能是GET,因此我们需要返回一个空表单(即便请求是其他类型的,返回一个空表单也不会有任何问题)。我们创建一个TopicForm实例(见❷),将其存储在变量form中,再通过上下文字典将这个表单发送给模板(见❼)。由于实例化TopicForm时我们没有指定任何实参,Django将创建一个可供用户填写的空表单。

如果请求方法为POST,将执行else代码块,对提交的表单数据进行处理。我们使用用户输入的数据(它们存储在request.POST中)创建一个TopicForm实例(见❸),这样对象form将包含用户提交的信息。

要将提交的信息保存到数据库,必须先通过检查确定它们是有效的(见❹)。函数is_valid()核实用户填写了所有必不可少的字段(表单字段默认都是必不可少的),且输入的数据与要求的字段类型一致(例如,字段text少于200个字符,这是我们在第18章中的models.py中指定的)。这种自动验证避免了我们去做大量的工作。如果所有字段都有效,我们就可调用save()(见❺),将表单中的数据写入数据库。保存数据后,就可离开这个页面了。我们使用reverse()获取页面topics的URL,并将其传递给HttpResponseRedirect()(见❻),后者将用户的浏览器重定向到页面topics。在页面topics中,用户将在主题列表中看到他刚输入的主题。

5. 模板new_topic

下面来创建新模板new_topic.html,用于显示我们刚创建的表单:

new_topic.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <p>Add a new topic:</p>
  4. <form action="{% url 'learning_logs:new_topic' %}" method='post'>
  5. {% csrf_token %}
  6. {{ form.as_p }}
  7. <button name="submit">add topic</button>
  8. </form>
  9. {% endblock content %}

这个模板继承了base.html,因此其基本结构与项目“学习笔记”的其他页面相同。在❶处,我们定义了一个HTML表单。实参action告诉服务器将提交的表单数据发送到哪里,这里我们将它发回给视图函数new_topic()。实参method让浏览器以POST请求的方式提交数据。

Django使用模板标签{% csrf_token %}(见❷)来防止攻击者利用表单来获得对服务器未经授权的访问(这种攻击被称为跨站请求伪造)。在❸处,我们显示表单,从中可知Django使得完成显示表单等任务有多简单:我们只需包含模板变量{{ form.as_p }},就可让Django自动创建显示表单所需的全部字段。修饰符as_p让Django以段落格式渲染所有表单元素,这是一种整洁地显示表单的简单方式。

Django不会为表单创建提交按钮,因此我们在❹处定义了一个这样的按钮。

6. 链接到页面new_topic

接下来,我们在页面topics中添加一个到页面new_topic的链接:

topics.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <p>Topics</p>
  4. <ul>
  5. --snip--
  6. </ul>
  7. <a href="{% url 'learning_logs:new_topic' %}">Add a new topic:</a>
  8. {% endblock content %}

这个链接放在了既有主题列表的后面。图19-1显示了生成的表单。请使用这个表单来添加几个新主题。

{%}

图19-1 用于添加新主题的页面

19.1.2 添加新条目

现在用户可以添加新主题了,但他们还想添加新条目。我们将再次定义URL,编写视图函数和模板,并链接到添加新条目的网页。但在此之前,我们需要在forms.py中再添加一个类。

1. 用于添加新条目的表单

我们需要创建一个与模型Entry相关联的表单,但这个表单的定制程度比TopicForm要高些:

forms.py

  1. from django import forms
  2. from .models import Topic, Entry
  3. class TopicForm(forms.ModelForm):
  4. --snip--
  5. class EntryForm(forms.ModelForm):
  6. class Meta:
  7. model = Entry
  8. fields = ['text']
  9. labels = {'text': ''}
  10. widgets = {'text': forms.Textarea(attrs={'cols': 80})}

我们首先修改了import语句,使其除导入Topic外,还导入Entry。新类EntryForm继承了forms.ModelForm,它包含的Meta类指出了表单基于的模型以及要在表单中包含哪些字段。这里也给字段'text'指定了一个空标签(见❶)。

在❷处,我们定义了属性widgets小部件(widget)是一个HTML表单元素,如单行文本框、多行文本区域或下拉列表。通过设置属性widgets,可覆盖Django选择的默认小部件。通过让Django使用forms.Textarea,我们定制了字段'text'的输入小部件,将文本区域的宽度设置为80列,而不是默认的40列。这给用户提供了足够的空间,可以编写有意义的条目。

2. URL模式new_entry

在用于添加新条目的页面的URL模式中,需要包含实参topic_id,因为条目必须与特定的主题相关联。该URL模式如下,我们将它添加到了learning_logs/urls.py中:

urls.py

  1. --snip--
  2. urlpatterns = [
  3. --snip--
  4. # 用于添加新条目的页面
  5. url(r'^new_entry/(?P<topic_id>\d+)/$', views.new_entry, name='new_entry'),
  6. ]

这个URL模式与形式为http://localhost:8000/new\_entry/id/的URL匹配,其中id是一个与主题ID匹配的数字。代码(?P\d+)捕获一个数字值,并将其存储在变量topic_id中。请求的URL与这个模式匹配时,Django将请求和主题ID发送给函数new_entry()

3. 视图函数new_entry()

视图函数new_entry()与函数new_topic()很像:

views.py

  1. from django.shortcuts import render
  2. --snip--
  3. from .models import Topic
  4. from .forms import TopicForm, EntryForm
  5. --snip--
  6. def new_entry(request, topic_id):
  7. """在特定的主题中添加新条目"""
  8. topic = Topic.objects.get(id=topic_id)
  9. if request.method != 'POST':
  10. # 未提交数据,创建一个空表单
  11. form = EntryForm()
  12. else:
  13. # POST提交的数据,对数据进行处理
  14. form = EntryForm(data=request.POST)
  15. if form.is_valid():
  16. new_entry = form.save(commit=False)
  17. new_entry.topic = topic
  18. new_entry.save()
  19. return HttpResponseRedirect(reverse('learning_logs:topic',
  20. args=[topic_id]))
  21. context = {'topic': topic, 'form': form}
  22. return render(request, 'learning_logs/new_entry.html', context)

我们修改了import语句,在其中包含了刚创建的EntryFormnew_entry()的定义包含形参topic_id,用于存储从URL中获得的值。渲染页面以及处理表单数据时,都需要知道针对的是哪个主题,因此我们使用topic_id来获得正确的主题(见❶)。

在❷处,我们检查请求方法是POST还是GET。如果是GET请求,将执行if代码块:创建一个空的EntryForm实例(见❸)。如果请求方法为POST,我们就对数据进行处理:创建一个EntryForm实例,使用request对象中的POST数据来填充它(见❹);再检查表单是否有效,如果有效,就设置条目对象的属性topic,再将条目对象保存到数据库。

调用save()时,我们传递了实参commit=False(见❺),让Django创建一个新的条目对象,并将其存储到new_entry中,但不将它保存到数据库中。我们将new_entry的属性topic设置为在这个函数开头从数据库中获取的主题(见❻),然后调用save(),且不指定任何实参。这将把条目保存到数据库,并将其与正确的主题相关联。

在❼处,我们将用户重定向到显示相关主题的页面。调用reverse()时,需要提供两个实参:要根据它来生成URL的URL模式的名称;列表args,其中包含要包含在URL中的所有实参。在这里,列表args只有一个元素——topic_id。接下来,调用HttpResponseRedirect()将用户重定向到显示新增条目所属主题的页面,用户将在该页面的条目列表中看到新添加的条目。

4. 模板new_entry

从下面的代码可知,模板new_entry类似于模板new_topic

new_entry.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
  4. <p>Add a new entry:</p>
  5. <form action="{% url 'learning_logs:new_entry' topic.id %}" method='post'>
  6. {% csrf_token %}
  7. {{ form.as_p }}
  8. <button name='submit'>add entry</button>
  9. </form>
  10. {% endblock content %}

我们在页面顶端显示了主题(见❶),让用户知道他是在哪个主题中添加条目;该主题名也是一个链接,可用于返回到该主题的主页面。

表单的实参action包含URL中的topic_id值,让视图函数能够将新条目关联到正确的主题(见❷)。除此之外,这个模板与模板new_topic.html完全相同。

5. 链接到页面new_entry

接下来,我们需要在显示特定主题的页面中添加到页面new_entry的链接:

topic.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <p>Topic: {{ topic }}</p>
  4. <p>Entries:</p>
  5. <p>
  6. <a href="{% url 'learning_logs:new_entry' topic.id %}">add new entry</a>
  7. </p>
  8. <ul>
  9. --snip
  10. </ul>
  11. {% endblock content %}

我们在显示条目前添加链接,因为在这种页面中,执行的最常见的操作是添加新条目。图19-2显示了页面new_entry。现在用户可以添加新主题,还可以在每个主题中添加任意数量的条目。请在一些既有主题中添加一些新条目,尝试使用一下页面new_entry

{%}

图19-2 页面new_entry

19.1.3 编辑条目

下面来创建一个页面,让用户能够编辑既有的条目。

1. URL模式edit_entry

这个页面的URL需要传递要编辑的条目的ID。修改后的learning_logs/urls.py如下:

urls.py

  1. --snip--
  2. urlpatterns = [
  3. --snip--
  4. # 用于编辑条目的页面
  5. url(r'^edit_entry/(?P<entry_id>\d+)/$', views.edit_entry,
  6. name='edit_entry'),
  7. ]

在URL(如http://localhost:8000/edit_entry/1/)中传递的ID存储在形参entry_id中。这个URL模式将预期匹配的请求发送给视图函数edit_entry()

2. 视图函数edit_entry()

页面edit_entry收到GET请求时,edit_entry()将返回一个表单,让用户能够对条目进行编辑。该页面收到POST请求(条目文本经过修订)时,它将修改后的文本保存到数据库中:

views.py

  1. from django.shortcuts import render
  2. --snip--
  3. from .models import Topic, Entry
  4. from .forms import TopicForm, EntryForm
  5. --snip--
  6. def edit_entry(request, entry_id):
  7. """编辑既有条目"""
  8. entry = Entry.objects.get(id=entry_id)
  9. topic = entry.topic
  10. if request.method != 'POST':
  11. # 初次请求,使用当前条目填充表单
  12. form = EntryForm(instance=entry)
  13. else:
  14. # POST提交的数据,对数据进行处理
  15. form = EntryForm(instance=entry, data=request.POST)
  16. if form.is_valid():
  17. form.save()
  18. return HttpResponseRedirect(reverse('learning_logs:topic',
  19. args=[topic.id]))
  20. context = {'entry': entry, 'topic': topic, 'form': form}
  21. return render(request, 'learning_logs/edit_entry.html', context)

我们首先需要导入模型Entry。在❶处,我们获取用户要修改的条目对象,以及与该条目相关联的主题。在请求方法为GET时将执行的if代码块中,我们使用实参instance=entry创建一个EntryForm实例(见❷)。这个实参让Django创建一个表单,并使用既有条目对象中的信息填充它。用户将看到既有的数据,并能够编辑它们。

处理POST请求时,我们传递实参instance=entrydata=request.POST(见❸),让Django根据既有条目对象创建一个表单实例,并根据request.POST中的相关数据对其进行修改。然后,我们检查表单是否有效,如果有效,就调用save(),且不指定任何实参(见❹)。接下来,我们重定向到显示条目所属主题的页面(见❺),用户将在其中看到其编辑的条目的新版本。

3. 模板edit_entry

下面是模板edit_entry.html,它与模板new_entry.html类似:

edit_entry.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
  4. <p>Edit entry:</p>
  5. <form action="{% url 'learning_logs:edit_entry' entry.id %}" method='post'>
  6. {% csrf_token %}
  7. {{ form.as_p }}
  8. <button name="submit">save changes</button>
  9. </form>
  10. {% endblock content %}

在❶处,实参action将表单发回给函数edit_entry()进行处理。在标签{% url %}中,我们将条目ID作为一个实参,让视图对象能够修改正确的条目对象。我们将提交按钮命名为save changes,以提醒用户:单击该按钮将保存所做的编辑,而不是创建一个新条目(见❷)。

4. 链接到页面edit_entry

现在,在显示特定主题的页面中,需要给每个条目添加到页面edit_entry的链接:

topic.html

  1. --snip--
  2. {% for entry in entries %}
  3. <li>
  4. <p>{{ entry.date_added|date:'M d, Y H:i' }}</p>
  5. <p>{{ entry.text|linebreaks }}</p>
  6. <p>
  7. <a href="{% url 'learning_logs:edit_entry' entry.id %}">edit entry</a>
  8. </p>
  9. </li>
  10. --snip--

我们将编辑链接放在每个条目的日期和文本后面。在循环中,我们使用模板标签{% url %}根据URL模式edit_entry和当前条目的ID属性(entry.id)来确定URL。链接文本为"edit entry",它出现在页面中每个条目的后面。图19-3显示了包含这些链接时,显示特定主题的页面是什么样的。

{%}

图19-3 每个条目都有一个用于对其进行编辑的链接

至此,“学习笔记”已具备了需要的大部分功能。用户可添加主题和条目,还可根据需要查看任何一组条目。在下一节,我们将实现一个用户注册系统,让任何人都可向“学习笔记”申请账户,并创建自己的主题和条目。

动手试一试

19-1 博客:新建一个Django项目,将其命名为Blog。在这个项目中,创建一个名为blogs的应用程序,并在其中创建一个名为BlogPost的模型。这个模型应包含titletextdate_added等字段。为这个项目创建一个超级用户,并使用管理网站创建几个简短的帖子。创建一个主页,在其中按时间顺序显示所有的帖子。

创建两个表单,其中一个用于发布新帖子,另一个用于编辑既有的帖子。

尝试填写这些表单,确认它们能够正确地工作。

19.2 创建用户账户

在这一节,我们将建立一个用户注册和身份验证系统,让用户能够注册账户,进而登录和注销。我们将创建一个新的应用程序,其中包含与处理用户账户相关的所有功能。我们还将对模型Topic稍做修改,让每个主题都归属于特定用户。

19.2.1 应用程序users

我们首先使用命令startapp来创建一个名为users的应用程序:

  1. (ll_env)learning_log$ python manage.py startapp users
  2. (ll_env)learning_log$ ls
  3. db.sqlite3 learning_log learning_logs ll_env manage.py users
  4. (ll_env)learning_log$ ls users
  5. admin.py __init__.py migrations models.py tests.py views.py

这个命令新建一个名为users的目录(见❶),其结构与应用程序learning_logs相同(见❷)。

1. 将应用程序users添加到settings.py中

在settings.py中,我们需要将这个新的应用程序添加到INSTALLED_APPS中,如下所示:

settings.py

  1. --snip--
  2. INSTALLED_APPS = (
  3. --snip--
  4. # 我的应用程序
  5. 'learning_logs',
  6. 'users',
  7. )
  8. --snip--

这样,Django将把应用程序users包含到项目中。

2. 包含应用程序users的URL

接下来,我们需要修改项目根目录中的urls.py,使其包含我们将为应用程序users定义的URL:

urls.py

  1. from django.conf.urls import include, url
  2. from django.contrib import admin
  3. urlpatterns = [
  4. url(r'^admin/', include(admin.site.urls)),
  5. url(r'^users/', include('users.urls', namespace='users')),
  6. url(r'', include('learning_logs.urls', namespace='learning_logs')),
  7. ]

我们添加了一行代码,以包含应用程序users中的文件urls.py。这行代码与任何以单词users打头的URL(如http://localhost:8000userslogin/)都匹配。我们还创建了命名空间'users',以便将应用程序learning_logs的URL同应用程序users的URL区分开来。

19.2.2 登录页面

我们首先来实现登录页面的功能。为此,我们将使用Django提供的默认登录视图,因此URL模式会稍有不同。在目录learning_logusers中,新建一个名为urls.py的文件,并在其中添加如下代码:

urls.py

  1. """为应用程序users定义URL模式"""
  2. from django.conf.urls import url
  3. from django.contrib.auth.views import login
  4. from . import views
  5. urlpatterns = [
  6. # 登录页面
  7. url(r'^login/$', login, {'template_name': 'users/login.html'},
  8. name='login'),
  9. ]

我们首先导入了默认视图login(见❶)。登录页面的URL模式与URL http://localhost:8000userslogin/匹配(见❷)。这个URL中的单词users让Django在users/urls.py中查找,而单词login让它将请求发送给Django默认视图login(请注意,视图实参为login,而不是views.login)。鉴于我们没有编写自己的视图函数,我们传递了一个字典,告诉Django去哪里查找我们将编写的模板。这个模板包含在应用程序users而不是learning_logs中。

1. 模板login.html

用户请求登录页面时,Django将使用其默认视图login,但我们依然需要为这个页面提供模板。为此,在目录learning_logusers中,创建一个名为templates的目录,并在其中创建一个名为users的目录。以下是模板login.html,你应将其存储到目录learning_loguserstemplatesusers中:

login.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. {% if form.errors %}
  4. <p>Your username and password didn't match. Please try again.</p>
  5. {% endif %}
  6. <form method="post" action="{% url 'users:login' %}"> ❷
  7. {% csrf_token %}
  8. {{ form.as_p }} ❸
  9. <button name="submit">log in</button> ❹
  10. <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" > ❺
  11. <form>
  12. {% endblock content %}

这个模板继承了base.html,旨在确保登录页面的外观与网站的其他页面相同。请注意,一个应用程序中的模板可继承另一个应用程序中的模板。

如果表单的errors属性被设置,我们就显示一条错误消息(见❶),指出输入的用户名—密码对与数据库中存储的任何用户名—密码对都不匹配。

我们要让登录视图处理表单,因此将实参action设置为登录页面的URL(见❷)。登录视图将一个表单发送给模板,在模板中,我们显示这个表单(见❸)并添加一个提交按钮(见❹)。在❺处,我们包含了一个隐藏的表单元素——'next',其中的实参value告诉Django在用户成功登录后将其重定向到什么地方——在这里是主页。

2. 链接到登录页面

下面在base.html中添加到登录页面的链接,让所有页面都包含它。用户已登录时,我们不想显示这个链接,因此将它嵌套在一个{% if %}标签中:

base.html

  1. <p>
  2. <a href="{% url 'learning_logs:index' %}">Learning Log</a> -
  3. <a href="{% url 'learning_logs:topics' %}">Topics</a> -
  4. {% if user.is_authenticated %} ❶
  5. Hello, {{ user.username }}. ❷
  6. {% else %}
  7. <a href="{% url 'users:login' %}">log in</a>
  8. {% endif %}
  9. </p>
  10. {% block content %}{% endblock content %}

在Django身份验证系统中,每个模板都可使用变量user,这个变量有一个is_authenticated属性:如果用户已登录,该属性将为True,否则为False。这让你能够向已通过身份验证的用户显示一条消息,而向未通过身份验证的用户显示另一条消息。

在这里,我们向已登录的用户显示一条问候语(见❶)。对于已通过身份验证的用户,还设置了属性username,我们使用这个属性来个性化问候语,让用户知道他已登录(见❷)。在❸处,对于还未通过身份验证的用户,我们再显示一个到登录页面的链接。

3. 使用登录页面

前面建立了一个用户账户,下面来登录一下,看看登录页面是否管用。请访问http://localhost:8000/admin/,如果你依然是以管理员的身份登录的,请在页眉上找到注销链接并单击它。

注销后,访问http://localhost:8000userslogin/,你将看到类似于图19-4所示的登录页面。输入你在前面设置的用户名和密码,将进入页面index。。在这个主页的页眉中,显示了一条个性化问候语,其中包含你的用户名。

{%}

图19-4 登录页面

19.2.3 注销

现在需要提供一个让用户注销的途径。我们不创建用于注销的页面,而让用户只需单击一个链接就能注销并返回到主页。为此,我们将为注销链接定义一个URL模式,编写一个视图函数,并在base.html中添加一个注销链接。

1. 注销URL

下面的代码为注销定义了URL模式,该模式与URL http://locallwst:8000userslogout/匹配。修改后的users/urls.py如下:

urls.py

  1. --snip--
  2. urlpatterns = [
  3. # 登录页面
  4. --snip--
  5. # 注销
  6. url(r'^logout/$', views.logout_view, name='logout'),
  7. ]

这个URL模式将请求发送给函数logout_view()。这样给这个函数命名,旨在将其与我们将在其中调用的函数logout()区分开来(请确保你修改的是users/urls.py,而不是learning_log/ urls.py)。

2. 视图函数logout_view()

函数logout_view()很简单:只是导入Django函数logout(),并调用它,再重定向到主页。请打开users/views.py,并输入下面的代码:

views.py

  1. from django.http import HttpResponseRedirect
  2. from django.core.urlresolvers import reverse
  3. from django.contrib.auth import logout
  4. def logout_view(request):
  5. """注销用户"""
  6. logout(request)
  7. return HttpResponseRedirect(reverse('learning_logs:index'))

我们从django.contrib.auth中导入了函数logout()(见❶)。在❷处,我们调用了函数logout(),它要求将request对象作为实参。然后,我们重定向到主页(见❸)。

3. 链接到注销视图

现在我们需要添加一个注销链接。我们在base.html中添加这种链接,让每个页面都包含它;我们将它放在标签{% if user.is_authenticated %}中,使得仅当用户登录后才能看到它:

base.html

  1. --snip
  2. {% if user.is_authenticated %}
  3. Hello, {{ user.username }}.
  4. <a href="{% url 'users:logout' %}">log out</a>
  5. {% else %}
  6. <a href="{% url 'users:login' %}">log in</a>
  7. {% endif %}
  8. --snip--

图19-5显示了用户登录后看到的主页。这里的重点是创建能够正确工作的网站,因此几乎没有设置任何样式。确定所需的功能都能正确运行后,我们将设置这个网站的样式,使其看起来更专业。

{%}

图19-5 包含个性化问候语和注销链接的主页

19.2.4 注册页面

下面来创建一个让新用户能够注册的页面。我们将使用Django提供的表单UserCreationForm,但编写自己的视图函数和模板。

1. 注册页面的URL模式

下面的代码定义了注册页面的URL模式,它也包含在users/urls.py中:

urls.py

  1. --snip--
  2. urlpatterns = [
  3. # 登录页面
  4. --snip--
  5. # 注册页面
  6. url(r'^register/$', views.register, name='register'),
  7. ]

这个模式与URL http://localhost:8000usersregister/匹配,并将请求发送给我们即将编写的函数register()

2. 视图函数register()

在注册页面首次被请求时,视图函数register()需要显示一个空的注册表单,并在用户提交填写好的注册表单时对其进行处理。如果注册成功,这个函数还需让用户自动登录。请在users/views.py中添加如下代码:

views.py

  1. from django.shortcuts import render
  2. from django.http import HttpResponseRedirect
  3. from django.core.urlresolvers import reverse
  4. from django.contrib.auth import login, logout, authenticate
  5. from django.contrib.auth.forms import UserCreationForm
  6. def logout_view(request):
  7. --snip--
  8. def register(request):
  9. """注册新用户"""
  10. if request.method != 'POST':
  11. # 显示空的注册表单
  12. form = UserCreationForm()
  13. else:
  14. # 处理填写好的表单
  15. form = UserCreationForm(data=request.POST)
  16. if form.is_valid():
  17. new_user = form.save()
  18. # 让用户自动登录,再重定向到主页
  19. authenticated_user = authenticate(username=new_user.username,
  20. password=request.POST['password1'])
  21. login(request, authenticated_user)
  22. return HttpResponseRedirect(reverse('learning_logs:index'))
  23. context = {'form': form}
  24. return render(request, 'users/register.html', context)

我们首先导入了函数render(),然后导入了函数login()authenticate(),以便在用户正确地填写了注册信息时让其自动登录。我们还导入了默认表单UserCreationForm。在函数register()中,我们检查要响应的是否是POST请求。如果不是,就创建一个UserCreationForm实例,且不给它提供任何初始数据(见❶)。

如果响应的是POST请求,我们就根据提交的数据创建一个UserCreationForm实例(见❷),并检查这些数据是否有效:就这里而言,是用户名未包含非法字符,输入的两个密码相同,以及用户没有试图做恶意的事情。

如果提交的数据有效,我们就调用表单的方法save(),将用户名和密码的散列值保存到数据库中(见❹)。方法save()返回新创建的用户对象,我们将其存储在new_user中。

保存用户的信息后,我们让用户自动登录,这包含两个步骤。首先,我们调用authenticate(),并将实参new_user.username和密码传递给它(见❺)。用户注册时,被要求输入密码两次;由于表单是有效的,我们知道输入的这两个密码是相同的,因此可以使用其中任何一个。在这里,我们从表单的POST数据中获取与键'password1'相关联的值。如果用户名和密码无误,方法authenticate()将返回一个通过了身份验证的用户对象,而我们将其存储在authenticated_user中。接下来,我们调用函数login(),并将对象requestauthenticated_user传递给它(见❻),这将为新用户创建有效的会话。最后,我们将用户重定向到主页(见❼),其页眉中显示了一条个性化的问候语,让用户知道注册成功了。

3. 注册模板

注册页面的模板与登录页面的模板类似,请务必将其保存到login.html所在的目录中:

register.html

  1. {% extends "learning_logs/base.html" %}
  2. {% block content %}
  3. <form method="post" action="{% url 'users:register' %}">
  4. {% csrf_token %}
  5. {{ form.as_p }}
  6. <button name="submit">register</button>
  7. <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" >
  8. <form>
  9. {% endblock content %}

这里也使用了方法as_p,让Django在表单中正确地显示所有的字段,包括错误消息——如果用户没有正确地填写表单。

4. 链接到注册页面

接下来,我们添加这样的代码,即在用户没有登录时显示到注册页面的链接:

base.html

  1. --snip--
  2. {% if user.is_authenticated %}
  3. Hello, {{ user.username }}.
  4. <a href="{% url 'users:logout' %}">log out</a>
  5. {% else %}
  6. <a href="{% url 'users:register' %}">register</a> -
  7. <a href="{% url 'users:login' %}">log in</a>
  8. {% endif %}
  9. --snip--

现在,已登录的用户看到的是个性化的问候语和注销链接,而未登录的用户看到的是注册链接和登录链接。请尝试使用注册页面创建几个用户名各不相同的用户账户。

在下一节,我们将对一些页面进行限制,仅让已登录的用户访问它们,我们还将确保每个主题都属于特定用户。

注意 这里的注册系统允许用户创建任意数量的账户。有些系统要求用户确认其身份:发送一封确认邮件,用户回复后其账户才生效。通过这样做,系统生成的垃圾账户将比这里使用的简单系统少。然而,学习创建应用程序时,完全可以像这里所做的那样,使用简单的用户注册系统。

动手试一试

19-2 博客账户:在你为完成练习19-1而开发的项目Blog中,添加一个用户身份验证和注册系统。让已登录的用户在屏幕上看到其用户名,并让未注册的用户看到一个到注册页面的链接。

19.3 让用户拥有自己的数据

用户应该能够输入其专有的数据,因此我们将创建一个系统,确定各项数据所属的用户,再限制对页面的访问,让用户只能使用自己的数据。

在本节中,我们将修改模型Topic,让每个主题都归属于特定用户。这也将影响条目,因为每个条目都属于特定的主题。我们先来限制对一些页面的访问。

19.3.1 使用@login_required限制访问

Django提供了装饰器@login_required,让你能够轻松地实现这样的目标:对于某些页面,只允许已登录的用户访问它们。装饰器(decorator)是放在函数定义前面的指令,Python在函数运行前,根据它来修改函数代码的行为。下面来看一个示例。

1. 限制对topics页面的访问

每个主题都归特定用户所有,因此应只允许已登录的用户请求topics页面。为此,在learning_logs/views.py中添加如下代码:

views.py

  1. --snip--
  2. from django.core.urlresolvers import reverse
  3. from django.contrib.auth.decorators import login_required
  4. from .models import Topic, Entry
  5. --snip--
  6. @login_required
  7. def topics(request):
  8. """显示所有的主题"""
  9. --snip--

我们首先导入了函数login_required()。我们将login_required()作为装饰器用于视图函数topics()——在它前面加上符号@login_required,让Python在运行topics()的代码前先运行login_required()的代码。

login_required()的代码检查用户是否已登录,仅当用户已登录时,Django才运行topics()的代码。如果用户未登录,就重定向到登录页面。

为实现这种重定向,我们需要修改settings.py,让Django知道到哪里去查找登录页面。请在settings.py末尾添加如下代码:

settings.py

  1. """
  2. 项目learning_log的Django设置
  3. --snip--
  4. # 我的设置
  5. LOGIN_URL = 'userslogin/'

现在,如果未登录的用户请求装饰器@login_required的保护页面,Django将重定向到settings.py中的LOGIN_URL指定的URL。

要测试这个设置,可注销并进入主页。然后,单击链接Topics,这将重定向到登录页面。接下来,使用你的账户登录,并再次单击主页中的Topics链接,你将看到topics页面。

2. 全面限制对项目“学习笔记”的访问

Django让你能够轻松地限制对页面的访问,但你必须针对要保护哪些页面做出决定。最好先确定项目的哪些页面不需要保护,再限制对其他所有页面的访问。你可以轻松地修改过于严格的访问限制,其风险比不限制对敏感页面的访问更低。

在项目“学习笔记”中,我们将不限制对主页、注册页面和注销页面的访问,并限制对其他所有页面的访问。

在下面的learning_logs/views.py中,对除index()外的每个视图都应用了装饰器@login_required

views.py

  1. --snip--
  2. @login_required
  3. def topics(request):
  4. --snip--
  5. @login_required
  6. def topic(request, topic_id):
  7. --snip--
  8. @login_required
  9. def new_topic(request):
  10. --snip--
  11. @login_required
  12. def new_entry(request, topic_id):
  13. --snip--
  14. @login_required
  15. def edit_entry(request, entry_id):
  16. --snip--

如果你在未登录的情况下尝试访问这些页面,将被重定向到登录页面。另外,你还不能单击到new_topic等页面的链接。但如果你输入URL http://localhost:8000/new_topic/,将重定向到登录页面。对于所有与私有用户数据相关的URL,都应限制对它们的访问。

19.3.2 将数据关联到用户

现在,需要将数据关联到提交它们的用户。我们只需将最高层的数据关联到用户,这样更低层的数据将自动关联到用户。例如,在项目“学习笔记”中,应用程序的最高层数据是主题,而所有条目都与特定主题相关联。只要每个主题都归属于特定用户,我们就能确定数据库中每个条目的所有者。

下面来修改模型Topic,在其中添加一个关联到用户的外键。这样做后,我们必须对数据库进行迁移。最后,我们必须对有些视图进行修改,使其只显示与当前登录的用户相关联的数据。

1. 修改模型Topic

对models.py的修改只涉及两行代码:

models.py

  1. from django.db import models
  2. from django.contrib.auth.models import User
  3. class Topic(models.Model):
  4. """用户要学习的主题"""
  5. text = models.CharField(max_length=200)
  6. date_added = models.DateTimeField(auto_now_add=True)
  7. owner = models.ForeignKey(User)
  8. def __str__(self):
  9. """返回模型的字符串表示"""
  10. return self.text
  11. class Entry(models.Model):
  12. --snip--

我们首先导入了django.contrib.auth中的模型User,然后在Topic中添加了字段owner,它建立到模型User的外键关系。

2. 确定当前有哪些用户

我们迁移数据库时,Django将对数据库进行修改,使其能够存储主题和用户之间的关联。为执行迁移,Django需要知道该将各个既有主题关联到哪个用户。最简单的办法是,将既有主题都关联到同一个用户,如超级用户。为此,我们需要知道该用户的ID。

下面来查看已创建的所有用户的ID。为此,启动一个Django shell会话,并执行如下命令:

  1. (venv)learning_log$ python manage.py shell
  2. >>> from django.contrib.auth.models import User
  3. >>> User.objects.all()
  4. [, , ]
  5. >>> for user in User.objects.all():
  6. ... print(user.username, user.id)
  7. ...
  8. ll_admin 1
  9. eric 2
  10. willie 3
  11. >>>

在❶处,我们在shell会话中导入了模型User。然后,我们查看到目前为止都创建了哪些用户(见❷)。输出中列出了三个用户:ll_admin、eric和willie。

在❸处,我们遍历用户列表,并打印每位用户的用户名和ID。Django询问要将既有主题关联到哪个用户时,我们将指定其中的一个ID值。

3. 迁移数据库

知道用户ID后,就可以迁移数据库了。

  1. (venv)learning_log$ python manage.py makemigrations learning_logs
  2. You are trying to add a non-nullable field 'owner' to topic without a default;
  3. we can't do that (the database needs something to populate existing rows).
  4. Please select a fix: ❸
  5. 1) Provide a one-off default now (will be set on all existing rows)
  6. 2) Quit, and let me add a default in models.py
  7. Select an option: 1 ❹
  8. Please enter the default value now, as valid Python ❺
  9. The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
  10. >>> 1 ❻
  11. Migrations for 'learning_logs':
  12. 0003_topic_owner.py:
  13. - Add field owner to topic

我们首先执行了命令makemigrations(见❶)。在❷处的输出中,Django指出我们试图给既有模型Topic添加一个必不可少(不可为空)的字段,而该字段没有默认值。在❸处,Django给我们提供了两种选择:要么现在提供默认值,要么退出并在models.py中添加默认值。在❹处,我们选择了第一个选项,因此Django让我们输入默认值(见❺)。

为将所有既有主题都关联到管理用户ll_admin,我输入了用户ID值1(见❻)。并非必须使用超级用户,而可使用已创建的任何用户的ID。接下来,Django使用这个值来迁移数据库,并生成了迁移文件0003_topic_owner.py,它在模型Topic中添加字段owner

现在可以执行迁移了。为此,在活动的虚拟环境中执行下面的命令:

  1. (venv)learning_log$ python manage.py migrate
  2. Operations to perform:
  3. Synchronize unmigrated apps: messages, staticfiles
  4. Apply all migrations: learning_logs, contenttypes, sessions, admin, auth
  5. --snip--
  6. Running migrations:
  7. Rendering model states... DONE
  8. Applying learning_logs.0003_topic_owner... OK
  9. (venv)learning_log$

Django应用新的迁移,结果一切顺利(见❶)。

为验证迁移符合预期,可在shell会话中像下面这样做:

  1. >>> from learning_logs.models import Topic
  2. >>> for topic in Topic.objects.all():
  3. ... print(topic, topic.owner)
  4. ...
  5. Chess ll_admin
  6. Rock Climbing ll_admin
  7. >>>

我们从learning_logs.models中导入Topic(见❶),再遍历所有的既有主题,并打印每个主题及其所属的用户(见❷)。正如你看到的,现在每个主题都属于用户ll_admin。

注意 你可以重置数据库而不是迁移它,但如果这样做,既有的数据都将丢失。一种不错的做法是,学习如何在迁移数据库的同时确保用户数据的完整性。如果你确实想要一个全新的数据库,可执行命令python manage.py flush,这将重建数据库的结构。如果你这样做,就必须重新创建超级用户,且原来的所有数据都将丢失。

19.3.3 只允许用户访问自己的主题

当前,不管你以哪个用户的身份登录,都能够看到所有的主题。我们来改变这种情况,只向用户显示属于自己的主题。

在views.py中,对函数topics()做如下修改:

views.py

  1. --snip--
  2. @login_required
  3. def topics(request):
  4. """显示所有的主题"""
  5. topics = Topic.objects.filter(owner=request.user).order_by('date_added')
  6. context = {'topics': topics}
  7. return render(request, 'learning_logs/topics.html', context)
  8. --snip--

用户登录后,request对象将有一个user属性,这个属性存储了有关该用户的信息。代码Topic.objects.filter(owner=request.user)让Django只从数据库中获取owner属性为当前用户的Topic对象。由于我们没有修改主题的显示方式,因此无需对页面topics的模板做任何修改。

要查看结果,以所有既有主题关联到的用户的身份登录,并访问topics页面,你将看到所有的主题。然后,注销并以另一个用户的身份登录,topics页面将不会列出任何主题。

19.3.4 保护用户的主题

我们还没有限制对显示单个主题的页面的访问,因此任何已登录的用户都可输入类似于http://localhost:8000/topics/1/的URL,来访问显示相应主题的页面。

你自己试一试就明白了。以拥有所有主题的用户的身份登录,访问特定的主题,并复制该页面的URL,或将其中的ID记录下来。然后,注销并以另一个用户的身份登录,再输入显示前述主题的页面的URL。虽然你是以另一个用户登录的,但依然能够查看该主题中的条目。

为修复这种问题,我们在视图函数topic()获取请求的条目前执行检查:

views.py

  1. from django.shortcuts import render
  2. from django.http import HttpResponseRedirect, Http404
  3. from django.core.urlresolvers import reverse
  4. --snip--
  5. @login_required
  6. def topic(request, topic_id):
  7. """显示单个主题及其所有的条目"""
  8. topic = Topic.objects.get(id=topic_id)
  9. # 确认请求的主题属于当前用户
  10. if topic.owner != request.user:
  11. raise Http404
  12. entries = topic.entry_set.order_by('-date_added')
  13. context = {'topic': topic, 'entries': entries}
  14. return render(request, 'learning_logs/topic.html', context)
  15. --snip--

服务器上没有请求的资源时,标准的做法是返回404响应。在这里,我们导入了异常Http404(见❶),并在用户请求它不能查看的主题时引发这个异常。收到主题请求后,我们在渲染网页前检查该主题是否属于当前登录的用户。如果请求的主题不归当前用户所有,我们就引发Http404异常(见❷),让Django返回一个404错误页面。

现在,如果你试图查看其他用户的主题条目,将看到Django发送的消息Page Not Found。在第20章,我们将对这个项目进行配置,让用户看到更合适的错误页面。

19.3.5 保护页面edit_entry

页面edit_entry的URL为http://localhost:8000/edit\_entry/entry\_id/,其中entry_id是一个数字。下面来保护这个页面,禁止用户通过输入类似于前面的URL来访问其他用户的条目:

views.py

  1. --snip--
  2. @login_required
  3. def edit_entry(request, entry_id):
  4. """编辑既有条目"""
  5. entry = Entry.objects.get(id=entry_id)
  6. topic = entry.topic
  7. if topic.owner != request.user:
  8. raise Http404
  9. if request.method != 'POST':
  10. # 初次请求,使用当前条目的内容填充表单
  11. --snip--

我们获取指定的条目以及与之相关联的主题,然后检查主题的所有者是否是当前登录的用户,如果不是,就引发Http404异常。

19.3.6 将新主题关联到当前用户

当前,用于添加新主题的页面存在问题,因此它没有将新主题关联到特定用户。如果你尝试添加新主题,将看到错误消息IntegrityError,指出learning_logs_topic.user_id不能为NULL。Django的意思是说,创建新主题时,你必须指定其owner字段的值。

由于我们可以通过request对象获悉当前用户,因此存在一个修复这种问题的简单方案。请添加下面的代码,将新主题关联到当前用户:

views.py

  1. --snip--
  2. @login_required
  3. def new_topic(request):
  4. """添加新主题"""
  5. if request.method != 'POST':
  6. # 没有提交的数据,创建一个空表单
  7. form = TopicForm()
  8. else:
  9. # POST提交的数据,对数据进行处理
  10. form = TopicForm(request.POST)
  11. if form.is_valid():
  12. new_topic = form.save(commit=False)
  13. new_topic.owner = request.user
  14. new_topic.save()
  15. return HttpResponseRedirect(reverse('learning_logs:topics'))
  16. context = {'form': form}
  17. return render(request, 'learning_logs/new_topic.html', context)
  18. --snip--

我们首先调用form.save(),并传递实参commit=False,这是因为我们先修改新主题,再将其保存到数据库中(见❶)。接下来,将新主题的owner属性设置为当前用户(见❷)。最后,对刚定义的主题实例调用save()(见❸)。现在主题包含所有必不可少的数据,将被成功地保存。

现在,这个项目允许任何用户注册,而每个用户想添加多少新主题都可以。每个用户都只能访问自己的数据,无论是查看数据、输入新数据还是修改旧数据时都如此。

动手试一试

19-3 重构:在views.py中,我们在两个地方核实主题关联到的用户为当前登录的用户。请将执行这种检查的代码放在一个名为check_topic_owner()的函数中,并在恰当的地方调用这个函数。

19-4 保护页面new_entry:一个用户可在另一个用户的学习笔记中添加条目,方法是输入这样的URL,即其中包含输入另一个用户的主题的ID。为防范这种攻击,请在保存新条目前,核实它所属的主题归当前用户所有。

19-5 受保护的博客:在你创建的项目Blog中,确保每篇博文都与特定用户相关联。确保任何用户都可访问所有的博文,但只有已登录的用户能够发表博文以及编辑既有博文。在让用户能够编辑其博文的视图中,在处理表单前确认用户编辑的是他自己发表的博文。

19.4 小结

在本章中,你学习了如何使用表单来让用户添加新主题、添加新条目和编辑既有条目。接下来,你学习了如何实现用户账户。你让老用户能够登录和注销,并学习了如何使用Django提供的表单UserCreationForm让用户能够创建新账户。

建立简单的用户身份验证和注册系统后,你通过使用装饰器@login_required禁止未登录的用户访问特定页面。然后,你通过使用外键将数据关联到特定用户,还学习了如何执行要求指定默认数据的数据库迁移。

最后,你学习了如何修改视图函数,让用户只能看到属于他的数据。你使用方法filter()来获取合适的数据,并学习了如何将请求的数据的所有者同当前登录的用户进行比较。

该让哪些数据可随便访问,该对哪些数据进行保护呢?这可能并非总是那么显而易见,但通过不断地练习就能掌握这种技能。在本章中,我们就该如何保护用户数据所做的决策表明,与人合作开发项目是个不错的主意:有人对项目进行检查的话,更容易发现其薄弱环节。

至此,我们创建了一个功能齐备的项目,它运行在本地计算机上。在本书的最后一章,我们将设置这个项目的样式,使其更漂亮;我们还将把它部署到一台服务器上,让任何人都可通过互联网注册并创建账户。