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

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

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

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

    线框图

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

    Wireframe Top Menu

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

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

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

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

    Wireframe log in page

    图3:登录页面

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

    图4:注册页面

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

    Wireframe password reset page

    图5: 密码重置

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

    图6:修改密码

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

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

    1. |-- myproject/
    2. | |-- accounts/ <-- 新创建的app
    3. | |-- boards/
    4. | |-- myproject/
    5. | |-- static/
    6. | |-- templates/
    7. | |-- db.sqlite3
    8. | +-- manage.py
    9. +-- 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章:用户注册 - 图4

    注册

    我们从创建注册视图开始。首先,在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 %}

    在浏览器中打开 ,看看是否程序运行了起来:

    接下来写点测试用例:

    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

    我在 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

    是时候创建注册表单了。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 %}

    哈?非常接近目标了,目前,我们的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. </small>
    9. {% endif %}
    10. {% 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视图冲突)。

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

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

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

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

    测试注册视图

    我们来改进测试用例:

    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)

    运行这个测试用例。

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

    一切都正常,但还缺失 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. if request.method == 'POST':
    5. if form.is_valid():
    6. user = form.save()
    7. auth_login(request, user)
    8. return redirect('home')
    9. else:
    10. form = SignUpForm()
    11. 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,以包含用户的名字和姓氏,那么即使我们没有破坏任何东西,我们也可能最终不得不修复一些测试用例。

    !

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

    改进注册模板

    让我们稍微讨论一下,在这里,我们可以使用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

    在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. {% endblock %}

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

    Sign up