Django入门与实践-第14章:用户注册

Django入门与实践-第14章:用户注册 - 图1

前言

这一章节将会全面介绍 Django 的身份认证系统,我们将实现注册、登录、注销、密码重置和密码修改的整套流程。

同时你还会了解到如何保护某些试图以防未授权的用户访问,以及如何访问已登录用户的个人信息。 {% raw %}

在接下来的部分,你会看到一些和身份验证有关线框图,将在本教程中实现。之后是一个全新Django 应用的初始化设置。至今为止我们一直在一个名叫 boards 的应用中开发。不过,所有身份认证相关的内容都将在另一个应用中,这样能更良好的组织代码。 Django入门与实践-第14章:用户注册 - 图2

线框图

我们必须更新一下应用的线框图。首先,我们需要在顶部菜单添加一些新选项,如果用户未通过身份验证,应该有两个按钮:分别是注册和登录按钮。

Wireframe Top Menu

图1: 未认证用户的菜单顶部

如果用户已经通过身份认证,我们应该显示他们的名字,和带有“我的账户”,“修改密码”,“登出”这三个选项的下拉框

Wireframe Top Menu

图2: 认证用户的顶部菜单

在登录页面,我们需要一个带有usernamepassword的表单, 一个登录的按钮和可跳转到注册页面和密码重置页面的链接。

Wireframe log in page

图3:登录页面

在注册页面,我们应该有包含四个字段的表单:username,email address, passwordpassword confirmation。同时,也应该有一个能够访问登录页面链接。

Wireframe sign up page

图4:注册页面

在密码重置页面上,只有email address字段的表单。

Wireframe password reset page

图5: 密码重置

之后,用户在点击带有特殊token的重置密码链接以后,用户将被重定向到一个页面,在那里他们可以设置新的密码。

Wireframe change password page

图6:修改密码

初始设置

要管理这些功能,我们可以在另一个应用(app)中将其拆解。在项目根目录中的 manage.py 文件所在的同一目录下,运行以下命令以创建一个新的app:

  1. django-admin startapp accounts

项目的目录结构应该如下:

  1. myproject/
  2. |-- myproject/
  3. | |-- accounts/ <-- 新创建的app
  4. | |-- boards/
  5. | |-- myproject/
  6. | |-- static/
  7. | |-- templates/
  8. | |-- db.sqlite3
  9. | +-- manage.py
  10. +-- venv/

下一步,在 settings.py 文件中将 accounts app 添加到INSTALLED_APPS

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

现在开始,我们将会在 accounts 这个app下操作。

Django入门与实践-第14章:用户注册 - 图9

注册

我们从创建注册视图开始。首先,在urls.py 文件中创建一个新的路由:

myproject/urls.py

  1. from django.conf.urls import url
  2. from django.contrib import admin
  3. from accounts import views as accounts_views
  4. from boards import views
  5. urlpatterns = [
  6. url(r'^$', views.home, name='home'),
  7. url(r'^signup/$', accounts_views.signup, name='signup'),
  8. url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
  9. url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
  10. url(r'^admin/', admin.site.urls),
  11. ]

注意,我们以不同的方式从accounts app 导入了views模块

  1. from accounts import views as accounts_views

我们给 accounts 的 views 指定了别名,否则它会与boardsviews 模块发生冲突。稍后我们可以改进urls.py 的设计,但现在,我们只关注身份验证功能。

现在,我们在 accounts app 中编辑 views.py,新创建一个名为signup的视图函数:

accounts/views.py

  1. from django.shortcuts import render
  2. def signup(request):
  3. return render(request, 'signup.html')

接着创建一个新的模板,取名为signup.html

templates/signup.html

  1. {% extends 'base.html' %}
  2. {% block content %}
  3. <h2>Sign up</h2>
  4. {% endblock %}

在浏览器中打开 http://127.0.0.1:8000/signup/ ,看看是否程序运行了起来:

Sign up

接下来写点测试用例:

accounts/tests.py

  1. from django.core.urlresolvers import reverse
  2. from django.urls import resolve
  3. from django.test import TestCase
  4. from .views import signup
  5. class SignUpTests(TestCase):
  6. def test_signup_status_code(self):
  7. url = reverse('signup')
  8. response = self.client.get(url)
  9. self.assertEquals(response.status_code, 200)
  10. def test_signup_url_resolves_signup_view(self):
  11. view = resolve('/signup/')
  12. self.assertEquals(view.func, signup)

测试状态码(200=success)以及 URL /signup/ 是否返回了正确的视图函数。

  1. python manage.py test
  1. Creating test database for alias 'default'...
  2. System check identified no issues (0 silenced).
  3. ..................
  4. ----------------------------------------------------------------------
  5. Ran 18 tests in 0.652s
  6. OK
  7. Destroying test database for alias 'default'...

对于认证视图(注册、登录、密码重置等),我们不需要顶部条和breadcrumb导航栏,但仍然能够复用base.html 模板,不过我们需要对它做出一些修改,只需要微调:

templates/base.html

  1. {% load static %}<!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <title>{% block title %}Django Boards{% endblock %}</title>
  6. <link href="https://fonts.googleapis.com/css?family=Peralta" rel="stylesheet">
  7. <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
  8. <link rel="stylesheet" href="{% static 'css/app.css' %}">
  9. {% block stylesheet %}{% endblock %} <!-- 这里 -->
  10. </head>
  11. <body>
  12. {% block body %} <!-- 这里 -->
  13. <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
  14. <div class="container">
  15. <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
  16. </div>
  17. </nav>
  18. <div class="container">
  19. <ol class="breadcrumb my-4">
  20. {% block breadcrumb %}
  21. {% endblock %}
  22. </ol>
  23. {% block content %}
  24. {% endblock %}
  25. </div>
  26. {% endblock body %} <!-- 这里 -->
  27. </body>
  28. </html>

我在 base.html 模板中标注了注释,表示新加的代码。块代码{% block stylesheet %}{% endblock %} 表示添加一些额外的CSS,用于某些特定的页面。

代码块{% block body %} 包装了整个HTML文档。我们可以只有一个空的文档结构,以充分利用base.html头部。注意,还有一个结束的代码块{% endblock body %},在这种情况下,命名结束标签是一种很好的实践方法,这样更容易确定结束标记的位置。

现在,在signup.html模板中,我们使用{% block body %}代替了 {% block content %}

templates/signup.html

  1. {% extends 'base.html' %}
  2. {% block body %}
  3. <h2>Sign up</h2>
  4. {% endblock %}

Sign up

Too Empty

是时候创建注册表单了。Django有一个名为 UserCreationForm的内置表单,我们就使用它吧:

accounts/views.py

  1. from django.contrib.auth.forms import UserCreationForm
  2. from django.shortcuts import render
  3. def signup(request):
  4. form = UserCreationForm()
  5. return render(request, 'signup.html', {'form': form})

templates/signup.html

  1. {% extends 'base.html' %}
  2. {% block body %}
  3. <div class="container">
  4. <h2>Sign up</h2>
  5. <form method="post" novalidate>
  6. {% csrf_token %}
  7. {{ form.as_p }}
  8. <button type="submit" class="btn btn-primary">Create an account</button>
  9. </form>
  10. </div>
  11. {% endblock %}

Sign up

看起来有一点乱糟糟,是吧?我们可以使用form.html模板使它看起来更好:

templates/signup.html

  1. {% extends 'base.html' %}
  2. {% block body %}
  3. <div class="container">
  4. <h2>Sign up</h2>
  5. <form method="post" novalidate>
  6. {% csrf_token %}
  7. {% include 'includes/form.html' %}
  8. <button type="submit" class="btn btn-primary">Create an account</button>
  9. </form>
  10. </div>
  11. {% endblock %}

Sign up

哈?非常接近目标了,目前,我们的form.html部分模板显示了一些原生的HTML代码。这是django出于安全考虑的特性。在默认的情况下,Django将所有字符串视为不安全的,会转义所有可能导致问题的特殊字符。但在这种情况下,我们可以信任它。

templates/includes/form.html

  1. {% load widget_tweaks %}
  2. {% for field in form %}
  3. <div class="form-group">
  4. {{ field.label_tag }}
  5. <!-- code suppressed for brevity -->
  6. {% if field.help_text %}
  7. <small class="form-text text-muted">
  8. {{ field.help_text|safe }} <!-- 新的代码 -->
  9. </small>
  10. {% endif %}
  11. </div>
  12. {% endfor %}

我们主要在之前的模板中,将选项safe 添加到field.help_text: {{ field.help_text|safe }}.

保存form.html文件,然后再次检测注册页面:

Sign up

现在,让我们在signup视图中实现业务逻辑:

accounts/views.py

  1. from django.contrib.auth import login as auth_login
  2. from django.contrib.auth.forms import UserCreationForm
  3. from django.shortcuts import render, redirect
  4. def signup(request):
  5. if request.method == 'POST':
  6. form = UserCreationForm(request.POST)
  7. if form.is_valid():
  8. user = form.save()
  9. auth_login(request, user)
  10. return redirect('home')
  11. else:
  12. form = UserCreationForm()
  13. return render(request, 'signup.html', {'form': form})

表单处理有一个小细节:login函数重命名为auth_login以避免与内置login视图冲突)。

(编者注:我重命名了login 函数重命名为auth_login ,但后来我意识到Django1.11对登录视图LoginView具有基于类的视图,因此不存在与名称冲突的风险。在比较旧的版本中,有一个auth.loginauth.view.login ,这会导致一些混淆,因为一个是用户登录的功能,另一个是视图。

简单来说:如果你愿意,你可以像login 一样导入它,这样做不会造成任何问题。)

如果表单是有效的,那么我们通过user=form.save()创建一个User实例。然后将创建的用户作为参数传递给auth_login函数,手动验证用户。之后,视图将用户重定向到主页,保持应用程序的流程。

让我们来试试吧,首先,提交一些无效数据,无论是空表单,不匹配的字段还是已有的用户名。

Sign up

现在填写表单并提交,检查用户是否已创建并重定向到主页。

Sign up

在模板中引用已认证的用户

我们要怎么才能知道上述操作是否有效呢?我们可以编辑base.html模板来在顶部栏上添加用户名称:

templates/base.html

  1. {% block body %}
  2. <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
  3. <div class="container">
  4. <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
  5. <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation">
  6. <span class="navbar-toggler-icon"></span>
  7. </button>
  8. <div class="collapse navbar-collapse" id="mainMenu">
  9. <ul class="navbar-nav ml-auto">
  10. <li class="nav-item">
  11. <a class="nav-link" href="#">{{ user.username }}</a>
  12. </li>
  13. </ul>
  14. </div>
  15. </div>
  16. </nav>
  17. <div class="container">
  18. <ol class="breadcrumb my-4">
  19. {% block breadcrumb %}
  20. {% endblock %}
  21. </ol>
  22. {% block content %}
  23. {% endblock %}
  24. </div>
  25. {% endblock body %}

4-17

测试注册视图

我们来改进测试用例:

accounts/tests.py

  1. from django.contrib.auth.forms import UserCreationForm
  2. from django.core.urlresolvers import reverse
  3. from django.urls import resolve
  4. from django.test import TestCase
  5. from .views import signup
  6. class SignUpTests(TestCase):
  7. def setUp(self):
  8. url = reverse('signup')
  9. self.response = self.client.get(url)
  10. def test_signup_status_code(self):
  11. self.assertEquals(self.response.status_code, 200)
  12. def test_signup_url_resolves_signup_view(self):
  13. view = resolve('/signup/')
  14. self.assertEquals(view.func, signup)
  15. def test_csrf(self):
  16. self.assertContains(self.response, 'csrfmiddlewaretoken')
  17. def test_contains_form(self):
  18. form = self.response.context.get('form')
  19. self.assertIsInstance(form, UserCreationForm)

我们稍微改变了SighUpTests类,定义了一个setUp方法,将response对象移到那里,现在我们测试响应中是否有表单和CSRF token。

现在我们要测试一个成功的注册功能。这次,让我们来创建一个新类,以便于更好地组织测试。

accounts/tests.py

  1. from django.contrib.auth.models import User
  2. from django.contrib.auth.forms import UserCreationForm
  3. from django.core.urlresolvers import reverse
  4. from django.urls import resolve
  5. from django.test import TestCase
  6. from .views import signup
  7. class SignUpTests(TestCase):
  8. # code suppressed...
  9. class SuccessfulSignUpTests(TestCase):
  10. def setUp(self):
  11. url = reverse('signup')
  12. data = {
  13. 'username': 'john',
  14. 'password1': 'abcdef123456',
  15. 'password2': 'abcdef123456'
  16. }
  17. self.response = self.client.post(url, data)
  18. self.home_url = reverse('home')
  19. def test_redirection(self):
  20. '''
  21. A valid form submission should redirect the user to the home page
  22. '''
  23. self.assertRedirects(self.response, self.home_url)
  24. def test_user_creation(self):
  25. self.assertTrue(User.objects.exists())
  26. def test_user_authentication(self):
  27. '''
  28. Create a new request to an arbitrary page.
  29. The resulting response should now have a `user` to its context,
  30. after a successful sign up.
  31. '''
  32. response = self.client.get(self.home_url)
  33. user = response.context.get('user')
  34. self.assertTrue(user.is_authenticated)

运行这个测试用例。

使用类似地策略,创建一个新的类,用于数据无效的注册用例

  1. from django.contrib.auth.models import User
  2. from django.contrib.auth.forms import UserCreationForm
  3. from django.core.urlresolvers import reverse
  4. from django.urls import resolve
  5. from django.test import TestCase
  6. from .views import signup
  7. class SignUpTests(TestCase):
  8. # code suppressed...
  9. class SuccessfulSignUpTests(TestCase):
  10. # code suppressed...
  11. class InvalidSignUpTests(TestCase):
  12. def setUp(self):
  13. url = reverse('signup')
  14. self.response = self.client.post(url, {}) # submit an empty dictionary
  15. def test_signup_status_code(self):
  16. '''
  17. An invalid form submission should return to the same page
  18. '''
  19. self.assertEquals(self.response.status_code, 200)
  20. def test_form_errors(self):
  21. form = self.response.context.get('form')
  22. self.assertTrue(form.errors)
  23. def test_dont_create_user(self):
  24. self.assertFalse(User.objects.exists())

将Email字段添加到表单

一切都正常,但还缺失 email address字段。UserCreationForm不提供 email 字段,但是我们可以对它进行扩展。

accounts 文件夹中创建一个名为forms.py的文件:

accounts/forms.py

  1. from django import forms
  2. from django.contrib.auth.forms import UserCreationForm
  3. from django.contrib.auth.models import User
  4. class SignUpForm(UserCreationForm):
  5. email = forms.CharField(max_length=254, required=True, widget=forms.EmailInput())
  6. class Meta:
  7. model = User
  8. fields = ('username', 'email', 'password1', 'password2')

现在,我们不需要在views.py 中使用UserCreationForm,而是导入新的表单SignUpForm,然后使用它:

accounts/views.py

  1. from django.contrib.auth import login as auth_login
  2. from django.shortcuts import render, redirect
  3. from .forms import SignUpForm
  4. def signup(request):
  5. if request.method == 'POST':
  6. form = SignUpForm(request.POST)
  7. if form.is_valid():
  8. user = form.save()
  9. auth_login(request, user)
  10. return redirect('home')
  11. else:
  12. form = SignUpForm()
  13. return render(request, 'signup.html', {'form': form})

只用这个小小的改变,可以运作了:

signup

请记住更改测试用例以使用SignUpForm而不是UserCreationForm:

  1. from .forms import SignUpForm
  2. class SignUpTests(TestCase):
  3. # ...
  4. def test_contains_form(self):
  5. form = self.response.context.get('form')
  6. self.assertIsInstance(form, SignUpForm)
  7. class SuccessfulSignUpTests(TestCase):
  8. def setUp(self):
  9. url = reverse('signup')
  10. data = {
  11. 'username': 'john',
  12. 'email': 'john@doe.com',
  13. 'password1': 'abcdef123456',
  14. 'password2': 'abcdef123456'
  15. }
  16. self.response = self.client.post(url, data)
  17. self.home_url = reverse('home')
  18. # ...

之前的测试用例仍然会通过,因为SignUpForm扩展了UserCreationForm,它是UserCreationForm的一个实例。

添加了新的表单后,让我们想想发生了什么:

  1. fields = ('username', 'email', 'password1', 'password2')

它会自动映射到HTML模板中。这很好吗?这要视情况而定。如果将来会有新的开发人员想要重新使用SignUpForm来做其他事情,并为其添加一些额外的字段。那么这些新的字段也会出现在signup.html中,这可能不是所期望的行为。这种改变可能会被忽略,我们不希望有任何意外。

那么让我们来创建一个新的测试,验证模板中的HTML输入:

accounts/tests.py

  1. class SignUpTests(TestCase):
  2. # ...
  3. def test_form_inputs(self):
  4. '''
  5. The view must contain five inputs: csrf, username, email,
  6. password1, password2
  7. '''
  8. self.assertContains(self.response, '<input', 5)
  9. self.assertContains(self.response, 'type="text"', 1)
  10. self.assertContains(self.response, 'type="email"', 1)
  11. self.assertContains(self.response, 'type="password"', 2)

改进测试代码的组织结构

好的,现在我们正在测试输入和所有的功能,但是我们仍然必须测试表单本身。不要只是继续向accounts/tests.py 文件添加测试,我们稍微改进一下项目设计。

accounts文件夹下创建一个名为tests的新文件夹。然后在tests文件夹中,创建一个名为init.py 的空文件。

现在,将test.py 文件移动到tests文件夹中,并将其重命名为test_view_signup.py

最终的结果应该如下:

  1. myproject/
  2. |-- myproject/
  3. | |-- accounts/
  4. | | |-- migrations/
  5. | | |-- tests/
  6. | | | |-- __init__.py
  7. | | | +-- test_view_signup.py
  8. | | |-- __init__.py
  9. | | |-- admin.py
  10. | | |-- apps.py
  11. | | |-- models.py
  12. | | +-- views.py
  13. | |-- boards/
  14. | |-- myproject/
  15. | |-- static/
  16. | |-- templates/
  17. | |-- db.sqlite3
  18. | +-- manage.py
  19. +-- venv/

注意到,因为我们在应用程序的上下文使用了相对导入,所以我们需要在 test_view_signup.py中修复导入:

accounts/tests/test_view_signup.py

  1. from django.contrib.auth.models import User
  2. from django.core.urlresolvers import reverse
  3. from django.urls import resolve
  4. from django.test import TestCase
  5. from ..views import signup
  6. from ..forms import SignUpForm

我们在应用程序模块内部使用相对导入,以便我们可以自由地重新命名Django应用程序,而无需修复所有绝对导入。

现在让我们创建一个新的测试文件来测试SignUpForm,添加一个名为test_form_signup.py的新测试文件:

accounts/tests/test_form_signup.py

  1. from django.test import TestCase
  2. from ..forms import SignUpForm
  3. class SignUpFormTest(TestCase):
  4. def test_form_has_fields(self):
  5. form = SignUpForm()
  6. expected = ['username', 'email', 'password1', 'password2',]
  7. actual = list(form.fields)
  8. self.assertSequenceEqual(expected, actual)

它看起来非常严格对吧,例如,如果将来我们必须更改SignUpForm,以包含用户的名字和姓氏,那么即使我们没有破坏任何东西,我们也可能最终不得不修复一些测试用例。

!Django入门与实践-第14章:用户注册 - 图20

这些警报很有用,因为它们有助于提高认识,特别是新手第一次接触代码,它可以帮助他们自信地编码。

改进注册模板

让我们稍微讨论一下,在这里,我们可以使用Bootstrap4 组件来使它看起来不错。

访问:https://www.toptal.com/designers/subtlepatterns/ 并找到一个很好地背景图案作为账户页面的背景,下载下来再静态文件夹中创建一个名为img的新文件夹,并将图像放置再那里。

之后,再static/css中创建一个名为accounts.css的新CSS文件。结果应该如下:

  1. myproject/
  2. |-- myproject/
  3. | |-- accounts/
  4. | |-- boards/
  5. | |-- myproject/
  6. | |-- static/
  7. | | |-- css/
  8. | | | |-- accounts.css <-- here
  9. | | | |-- app.css
  10. | | | +-- bootstrap.min.css
  11. | | +-- img/
  12. | | | +-- shattered.png <-- here (the name may be different, depending on the patter you downloaded)
  13. | |-- templates/
  14. | |-- db.sqlite3
  15. | +-- manage.py
  16. +-- venv/

现在编辑accounts.css这个文件:

static/css/accounts.css

  1. body {
  2. background-image: url(../img/shattered.png);
  3. }
  4. .logo {
  5. font-family: 'Peralta', cursive;
  6. }
  7. .logo a {
  8. color: rgba(0,0,0,.9);
  9. }
  10. .logo a:hover,
  11. .logo a:active {
  12. text-decoration: none;
  13. }

在signup.html模板中,我们可以将其改为使用新的CSS,并使用Bootstrap4组件:

templates/signup.html

  1. {% extends 'base.html' %}
  2. {% load static %}
  3. {% block stylesheet %}
  4. <link rel="stylesheet" href="{% static 'css/accounts.css' %}">
  5. {% endblock %}
  6. {% block body %}
  7. <div class="container">
  8. <h1 class="text-center logo my-4">
  9. <a href="{% url 'home' %}">Django Boards</a>
  10. </h1>
  11. <div class="row justify-content-center">
  12. <div class="col-lg-8 col-md-10 col-sm-12">
  13. <div class="card">
  14. <div class="card-body">
  15. <h3 class="card-title">Sign up</h3>
  16. <form method="post" novalidate>
  17. {% csrf_token %}
  18. {% include 'includes/form.html' %}
  19. <button type="submit" class="btn btn-primary btn-block">Create an account</button>
  20. </form>
  21. </div>
  22. <div class="card-footer text-muted text-center">
  23. Already have an account? <a href="#">Log in</a>
  24. </div>
  25. </div>
  26. </div>
  27. </div>
  28. </div>
  29. {% endblock %}

这就是我们现在的注册页面:

Sign up

{% endraw %}