美文网首页
Django初学者入门指南4-登录认证(译&改)

Django初学者入门指南4-登录认证(译&改)

作者: 心甘情愿_Root | 来源:发表于2020-09-24 18:39 被阅读0次

    Django初学者入门指南1-初识(译&改)

    Django初学者入门指南2-基础知识(译&改)

    Django初学者入门指南3-高级概念(译&改)

    Django初学者入门指南4-登录认证(译&改)

    Django初学者入门指南5-存储数据(译&改)

    Django初学者入门指南6-基于类的页面(译&改)

    Django初学者入门指南7-部署发布(译&改)

    >>原文地址 By Vitor Freitas

    简介

    本教程将介绍Django的身份验证系统。我们将实现整个过程:注册、登录、退出登录、密码重置和密码更改。

    还将简要介绍如何防止未经授权的用户访问某些页面,以及已登录用户如何访问这些被保护的信息。

    在本次教程中,我们将根据线框图实现与身份验证相关的页面,同时会创建一个新的Django应用程序并对它进行初始化设置。到目前为止,我们一直在开发的是名为boards的应用程序。而所有与身份验证相关的东西都可以放在不同的应用程序中,这样的架构设计更加合理,有利于后期维护。

    账号系统应用程序

    线框图

    我们需要更新应用程序的线框图:首先我们需要在顶部菜单添加更多的新选项,当用户没有登录时,显示注册和登录两个按钮。

    图1:未登录用户的顶部条样式

    如果用户已经登录了,那么就需要在这个位置显示用户名,同时提供三个选项的一个下拉菜单:我的账号、修改密码、退出登录。

    图2:已登录用户的顶部条样式

    我们再来设计登录页面,这里需要一个表单Form,包含usernamepassword字段,一个主要功能按钮(登录)以及两个跳转其他页面的按钮:注册页面和重置密码。

    图3:登录页面

    在注册页面我们需要一个包含usernameemail addresspasswordpassword confirmation四个字段的表单Form,同样也需要提供可以回到登录页面的按钮。

    图4:注册页面

    在密码重置页面,我们只需要一个包含email address字段的表单。

    图5:密码重置页面

    当用户发起重置密码成功后,会通过邮件中的带特殊token链接跳转到下面这个设置密码的页面:

    图6:设置密码

    创建并初始化账号系统应用程序

    为了方便管理这些信息和功能,我们来创建一个新的应用程序。回到项目的根目录,也就是manage.py文件所在的目录,运行下面的命令:

    django-admin startapp accounts
    

    现在的项目目录结构应该如下所示:

    myproject/
     |-- myproject/
     |    |-- accounts/     <-- 新的账号系统应用程序
     |    |-- boards/
     |    |-- myproject/
     |    |-- static/
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    将新的accounts应用程序配置到settings.py文件下的INSTALLED_APPS里:

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
    
        'widget_tweaks',
    
        'accounts',
        'boards',
    ]
    

    接下来,让我们在accounts这个新的应用程序里开发吧:


    注册

    让我们先来创建注册页面,第一步,将新的路由添加到urls.py文件里:

    myproject/urls.py

    <details>
    <summary>原始版本</summary>

    from django.conf.urls import url
    from django.contrib import admin
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        url(r'^$', views.home, name='home'),
        url(r'^signup/$', accounts_views.signup, name='signup'),
        url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        url(r'^admin/', admin.site.urls),
    ]
    

    </details>

    <details open>
    <summary>修订版本</summary>

    from django.urls import re_path
    from django.contrib import admin
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        re_path(r'^$', views.home, name='home'),
        re_path(r'^signup/$', accounts_views.signup, name='signup'),
        re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        re_path(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        re_path(r'^admin/', admin.site.urls),
    ]
    

    </details>

    需要注意的是这次我们引入应用程序accountsviews时,使用了不同的写法:

    from accounts import views as accounts_views
    

    我们给它起了一个别名accounts_views,否则它会跟boardsviews命名冲突。后面我们再来优化urls.py,现在让我们先集中精力做好账号系统。

    现在我们把signup页面方法添加到下面的代码到accounts应用目录下的views.py文件里:

    accounts/views.py

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

    创建注册的页面模板signup.html

    templates/signup.html

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

    在浏览器中访问http://127.0.0.1:8000/signup/看是否能正常运行:

    注册页面

    再添加一点测试代码:

    accounts/tests.py

    # from django.core.urlresolvers import reverse #新版Django汇总到了urls里
    from django.urls import resolve, reverse
    from django.test import TestCase
    from .views import signup
    
    class SignUpTests(TestCase):
        def test_signup_status_code(self):
            url = reverse('signup')
            response = self.client.get(url)
            self.assertEquals(response.status_code, 200)
    
        def test_signup_url_resolves_signup_view(self):
            view = resolve('/signup/')
            self.assertEquals(view.func, signup)
    

    测试请求状态码是否为成功(200 = success),并试试/signup/能否正常访问到正确的页面方法。

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

    在新的用户认证相关页面(注册、登录、重置密码等),不会用到我们之前添加的顶部导航条,但仍需要使用base.html这个母模板。让我们稍微改一改它:

    templates/base.html

    {% load static %}<!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <title>{% block title %}Django Boards{% endblock %}</title>
        <link href="https://fonts.googleapis.com/css?family=Peralta" rel="stylesheet">
        <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
        <link rel="stylesheet" href="{% static 'css/app.css' %}">
        {% block stylesheet %}{% endblock %}  <!-- 修改这里 -->
      </head>
      <body>
        {% block body %}  <!-- 修改这里 -->
          <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
            <div class="container">
              <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
            </div>
          </nav>
          <div class="container">
            <ol class="breadcrumb my-4">
              {% block breadcrumb %}
              {% endblock %}
            </ol>
            {% block content %}
            {% endblock %}
          </div>
        {% endblock body %}  <!-- 和这里 -->
      </body>
    </html>
    

    我在base.html文件新修改的地方添加了注释。新增的块{% block stylesheet %}{% endblock %}用于配置各子页面自需要自定义的样式表stylesheet

    {% block body %}这个块包含也整个页面的body部分,我们可以利用base.html来创建一个空文档。需要注意的是,在块结束部分我们使用了{% endblock body %}。在比较复杂的场景下,建议也给结束标记添加名称,这样可以很直观的看清整个文档。

    回到注册页面signup.html,现在我们更换{% block content %}{% block body %}

    templates/signup.html

    {% extends 'base.html' %}
    
    {% block body %}
      <h2>Sign up</h2>
    {% endblock %}
    
    注册页面

    现在来创建注册请求表单吧,Django有一个内置的类UserCreationForm,让我们用起来吧:

    accounts/views.py

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

    templates/signup.html

    {% extends 'base.html' %}
    
    {% block body %}
      <div class="container">
        <h2>Sign up</h2>
        <form method="post" novalidate>
          {% csrf_token %}
          {{ form.as_p }}
          <button type="submit" class="btn btn-primary">Create an account</button>
        </form>
      </div>
    {% endblock %}
    
    注册页面

    看起来页面元素有点乱,让我们优化一下form.html来解决这个问题:

    templates/signup.html

    {% extends 'base.html' %}
    
    {% block body %}
      <div class="container">
        <h2>Sign up</h2>
        <form method="post" novalidate>
          {% csrf_token %}
          {% include 'includes/form.html' %}
          <button type="submit" class="btn btn-primary">Create an account</button>
        </form>
      </div>
    {% endblock %}
    
    注册页面

    看起来好多了,form.html这个文件某些地方现在还在显示原始的HTML字符串。这是一个安全的功能,Django默认会把所以字符串看作不安全的,并把所有可能导致异常问题的特殊符号排除在外。不过现在,我们可以先禁用这个功能。

    templates/includes/form.html

    {% load widget_tweaks %}
    
    {% for field in form %}
      <div class="form-group">
        {{ field.label_tag }}
    
        <!-- 这里的代码忽略了,并不是删掉了哈 -->
    
        {% if field.help_text %}
          <small class="form-text text-muted">
            {{ field.help_text|safe }}  <!-- 更新了这个 -->
          </small>
        {% endif %}
      </div>
    {% endfor %}
    

    这里就是把safe添加给了field.help_text得到:{{ field.help_text|safe }}

    保存form.html文件,让我们重新打开注册页面看一看:

    注册页面

    现在我们把业务逻辑添加到signup页面方法里:

    accounts/views.py

    from django.contrib.auth import login as auth_login
    from django.contrib.auth.forms import UserCreationForm
    from django.shortcuts import render, redirect
    
    def signup(request):
        if request.method == 'POST':
            form = UserCreationForm(request.POST)
            if form.is_valid():
                user = form.save()
                auth_login(request, user)
                return redirect('home')
        else:
            form = UserCreationForm()
        return render(request, 'signup.html', {'form': form})
    

    这里有个小细节:引入的登录login方法被重命名为了auth_login,这是为了防止跟login这个内置登录页面出现冲突。

    提示: 这里我将login重命名为了auth_login,后来我注意到Django 1.11已经实现了基于类实现的页面LoginView所以这里其实没有命名冲突的风险。

    在更早的Django版本里,存在这样的内置方法auth.loginauth.view.login,这两个login一个是页面而另一个是方法,比较容易出现冲突。

    长话短说,你可以直接使用login,它不会导致任何问题。

    User实例会在用户提交的数据被验证通过后直接调用user = form.save()创建并保存,然后被创建的用户实例会被作为参数传递给auth_login方法来主动验证用户信息,最后页面会重新回到首页,保证正常的使用流程。

    让我们试一试,先提交一些不合法的数据,如空的表单、不符合规则的文字、或者已经存在的用户名:

    各种异常

    现在让我们输入正确的信息,看是否能注册成功并正确跳转到首页:

    回到首页
    已登录用户的页面显示

    我们怎么知道用户已经登录呢?让我们先在base.html母模板的顶部条添加用户名吧:

    templates/base.html

    {% block body %}
      <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
        <div class="container">
          <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
          <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
          </button>
          <div class="collapse navbar-collapse" id="mainMenu">
            <ul class="navbar-nav ml-auto">
              <li class="nav-item">
                <a class="nav-link" href="#">{{ user.username }}</a>
              </li>
            </ul>
          </div>
        </div>
      </nav>
    
      <div class="container">
        <ol class="breadcrumb my-4">
          {% block breadcrumb %}
          {% endblock %}
        </ol>
        {% block content %}
        {% endblock %}
      </div>
    {% endblock body %}
    
    注册页面测试

    让我们更新一下测试用例:

    accounts/tests.py

    from django.contrib.auth.forms import UserCreationForm
    #from django.core.urlresolvers import reverse #新版本迁移到了下一行
    from django.urls import resolve, reverse
    from django.test import TestCase
    from .views import signup
    
    class SignUpTests(TestCase):
        def setUp(self):
            url = reverse('signup')
            self.response = self.client.get(url)
    
        def test_signup_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_signup_url_resolves_signup_view(self):
            view = resolve('/signup/')
            self.assertEquals(view.func, signup)
    
        def test_csrf(self):
            self.assertContains(self.response, 'csrfmiddlewaretoken')
    
        def test_contains_form(self):
            form = self.response.context.get('form')
            self.assertIsInstance(form, UserCreationForm)
    

    修改了SignUpTests类,定义了它的setUp方法,将请求返回放到了这里。现在我们也来测试一下是否包含表单数据和CSRF token

    让我们创建一个新的测试类来测试成功的注册行为:

    accounts/tests.py

    from django.contrib.auth.models import User
    from django.contrib.auth.forms import UserCreationForm
    #from django.core.urlresolvers import reverse #新版本迁移到了下一行
    from django.urls import resolve, reverse
    from django.test import TestCase
    from .views import signup
    
    class SignUpTests(TestCase):
        # 代码没显示,别删除了...
    
    class SuccessfulSignUpTests(TestCase):
        def setUp(self):
            url = reverse('signup')
            data = {
                'username': 'john',
                'password1': 'abcdef123456',
                'password2': 'abcdef123456'
            }
            self.response = self.client.post(url, data)
            self.home_url = reverse('home')
    
        def test_redirection(self):
            '''
            A valid form submission should redirect the user to the home page
            '''
            self.assertRedirects(self.response, self.home_url)
    
        def test_user_creation(self):
            self.assertTrue(User.objects.exists())
    
        def test_user_authentication(self):
            '''
            Create a new request to an arbitrary page.
            The resulting response should now have a `user` to its context,
            after a successful sign up.
            '''
            response = self.client.get(self.home_url)
            user = response.context.get('user')
            self.assertTrue(user.is_authenticated)
    

    运行单元测试吧。

    使用同样的方式,我们再创建一个新测试类来测试非法注册请求:

    from django.contrib.auth.models import User
    from django.contrib.auth.forms import UserCreationForm
    #from django.core.urlresolvers import reverse #新版本迁移到了下一行
    from django.urls import resolve, reverse
    from django.test import TestCase
    from .views import signup
    
    class SignUpTests(TestCase):
        # code suppressed...
    
    class SuccessfulSignUpTests(TestCase):
        # code suppressed...
    
    class InvalidSignUpTests(TestCase):
        def setUp(self):
            url = reverse('signup')
            self.response = self.client.post(url, {})  # submit an empty dictionary
    
        def test_signup_status_code(self):
            '''
            An invalid form submission should return to the same page
            '''
            self.assertEquals(self.response.status_code, 200)
    
        def test_form_errors(self):
            form = self.response.context.get('form')
            self.assertTrue(form.errors)
    
        def test_dont_create_user(self):
            self.assertFalse(User.objects.exists())
    
    添加电子邮箱

    看起来一切正常,但缺失了email address字段。Django内置的类UserCreationForm不包含email字段。所以我们要扩展它。
    创建一个新的forms.py文件,并放到accounts应用目录下:

    accounts/forms.py

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

    现在我们将views.py文件里的UserCreationForm替换为新的表单类SignUpForm

    accounts/views.py

    from django.contrib.auth import login as auth_login
    from django.shortcuts import render, redirect
    
    from .forms import SignUpForm
    
    def signup(request):
        if request.method == 'POST':
            form = SignUpForm(request.POST)
            if form.is_valid():
                user = form.save()
                auth_login(request, user)
                return redirect('home')
        else:
            form = SignUpForm()
        return render(request, 'signup.html', {'form': form})
    

    这样简单改动一下,就可以了:

    这里记得把测试用例里的UserCreationForm也同样改为SignUpForm

    from .forms import SignUpForm
    
    class SignUpTests(TestCase):
        # ...
    
        def test_contains_form(self):
            form = self.response.context.get('form')
            self.assertIsInstance(form, SignUpForm)
    
    class SuccessfulSignUpTests(TestCase):
        def setUp(self):
            url = reverse('signup')
            data = {
                'username': 'john',
                'email': 'john@doe.com',
                'password1': 'abcdef123456',
                'password2': 'abcdef123456'
            }
            self.response = self.client.post(url, data)
            self.home_url = reverse('home')
    
        # ...
    

    之前写的测试用例直接运行也能通过,因为SignUpFormUserCreationForm的子类,生成的实例也包含UserCreationForm的所有属性。

    我们新增了一个表单域:

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

    HTML模板会自动根据这个进行页面渲染,方便又快捷。但是如果未来SignUpForm被继续扩展新增其他字段,这些新的字段也会显示到这个注册页面上,这并不是我们想看到的情况。

    所以让我们添加一个新的单元测试,验证这个模板中的HTML输入的字段:

    accounts/tests.py

    class SignUpTests(TestCase):
        # ...
    
        def test_form_inputs(self):
            '''
            The view must contain five inputs: csrf, username, email,
            password1, password2
            '''
            self.assertContains(self.response, '<input', 5)
            self.assertContains(self.response, 'type="text"', 1)
            self.assertContains(self.response, 'type="email"', 1)
            self.assertContains(self.response, 'type="password"', 2)
    
    测试代码的管理

    好了,我们需要测试输入以及其他可能的所有功能,还需要校验表单数据。这种情况下继续把所有的测试代码都写到accounts/tests.py这个文件里就会显得很臃肿,让我们改进测试代码的管理吧。

    accounts应用目录下创建文件夹tests,然后在文件夹里创建一个空白文档命名为__init__.py

    再把tests.py文件移动到新建的tests文件夹里,并将文件改名为test_view_signup.py

    最终的项目文件结构如下所示:

    myproject/
     |-- myproject/
     |    |-- accounts/
     |    |    |-- migrations/
     |    |    |-- tests/
     |    |    |    |-- __init__.py
     |    |    |    +-- test_view_signup.py
     |    |    |-- __init__.py
     |    |    |-- admin.py
     |    |    |-- apps.py
     |    |    |-- models.py
     |    |    +-- views.py
     |    |-- boards/
     |    |-- myproject/
     |    |-- static/
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    注意现在因为调整了文档目录结构,所以我们需要修改测试文件test_view_signup.py里的引入部分import:

    accounts/tests/test_view_signup.py

    from django.contrib.auth.models import User
    #from django.core.urlresolvers import reverse
    from django.urls import resolve, reverse
    from django.test import TestCase
    
    from ..views import signup
    from ..forms import SignUpForm
    

    这里我们使用相对路径进行引入,这样就避免了因为项目改变位置而导致错误的情况。

    首先创建一个新的文件test_form_signup.py表单类SignUpForm

    accounts/tests/test_form_signup.py

    from django.test import TestCase
    from ..forms import SignUpForm
    
    class SignUpFormTest(TestCase):
        def test_form_has_fields(self):
            form = SignUpForm()
            expected = ['username', 'email', 'password1', 'password2',]
            actual = list(form.fields)
            self.assertSequenceEqual(expected, actual)
    

    这里看起来数据校验非常严格,比如未来我们为SignUpForm类新增了姓和名等属性时,也同样需要到这里来修改测试用例。

    测试用例

    这种严格校验的测试用例在实际生产中非常有用,有助于新来的人了解项目代码。

    改进注册页面模板

    让我们给注册页面模板添加一个Bootstrap 4卡片式背景板。

    打开https://www.toptal.com/designers/subtlepatterns/选一个你喜欢的背景图片下载下来,在项目static文件夹下创建一个文件夹img,把图片都存储到这里。

    然后在static/css目录下创建一个新文件accounts.css,现在我们的目录结果应该是这样的:

    myproject/
     |-- myproject/
     |    |-- accounts/
     |    |-- boards/
     |    |-- myproject/
     |    |-- static/
     |    |    |-- css/
     |    |    |    |-- accounts.css  <-- 这里
     |    |    |    |-- app.css
     |    |    |    +-- bootstrap.min.css
     |    |    +-- img/
     |    |    |    +-- shattered.png  <-- 文件名可以随意,这里使用的是下载默认名称
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    修改一下accounts.css文件:

    static/css/accounts.css

    body {
      background-image: url(../img/shattered.png);
    }
    
    .logo {
      font-family: 'Peralta', cursive;
    }
    
    .logo a {
      color: rgba(0,0,0,.9);
    }
    
    .logo a:hover,
    .logo a:active {
      text-decoration: none;
    }
    

    然后在signup.html模板中,我们加载新的css文件,来应用这个背景板:

    templates/signup.html

    {% extends 'base.html' %}
    
    {% load static %}
    
    {% block stylesheet %}
      <link rel="stylesheet" href="{% static 'css/accounts.css' %}">
    {% endblock %}
    
    {% block body %}
      <div class="container">
        <h1 class="text-center logo my-4">
          <a href="{% url 'home' %}">Django Boards</a>
        </h1>
        <div class="row justify-content-center">
          <div class="col-lg-8 col-md-10 col-sm-12">
            <div class="card">
              <div class="card-body">
                <h3 class="card-title">Sign up</h3>
                <form method="post" novalidate>
                  {% csrf_token %}
                  {% include 'includes/form.html' %}
                  <button type="submit" class="btn btn-primary btn-block">Create an account</button>
                </form>
              </div>
              <div class="card-footer text-muted text-center">
                Already have an account? <a href="#">Log in</a>
              </div>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    

    刷新一下页面:


    退出登录

    让我们编辑urls.py文件,新增一个url路由:

    myproject/urls.py

    <details>
    <summary>原始版本</summary>

    from django.conf.urls import url
    from django.contrib import admin
    from django.contrib.auth import views as auth_views
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        url(r'^$', views.home, name='home'),
        url(r'^signup/$', accounts_views.signup, name='signup'),
        url(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
        url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        url(r'^admin/', admin.site.urls),
    ]
    

    </details>

    <details open>
    <summary>修订版本</summary>

    from django.urls import re_path
    from django.contrib import admin
    from django.contrib.auth import views as auth_views
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        re_path(r'^$', views.home, name='home'),
        re_path(r'^signup/$', accounts_views.signup, name='signup'),
        re_path(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
        re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        re_path(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        re_path(r'^admin/', admin.site.urls),
    ]
    

    </details>

    我们从Django的contrib模块引入了views并命名为auth_views来避免与boards.views发生冲突。还有你可能注意到了LogoutView.as_view(),它是DJango的基于类实现的视图。目前为止我们使用的都是使用Python方法生成的页面,而基于类实现的视图可以更加灵活的扩展和重用,后面我们会渗入讨论这个问题。

    打开settings.py文件,将LOGOUT_REDIRECT_URL添加到文档底部:

    myproject/settings.py

    LOGOUT_REDIRECT_URL = 'home'
    

    我们将退出登录以后重定位跳转的页面定义为home

    好了,这就可以了,只要通过访问http://127.0.0.1:8000/logout/就能实现退出登录的功能。在退出登录之前,我们先为已登录的用户实现一个下拉菜单。


    已登录用户的下拉菜单

    现在我们又需要对base.html母模板进行修改,增加一个可以退出登录的下拉菜单。

    Bootstrap 4的下拉菜单组件需要依赖jQuery

    打开jquery.com/download/下载compressed, production jQuery 3.2.1这个版本.

    下载jQuery

    打开项目的static文件夹,并且创建一个名为js的文件夹,将下载下来的文件里的jquery-3.2.1.min.js添加到这个新建文件夹下。

    Bootstrap 4也需要依赖Popper。打开popper.js.org下载最新的版本。

    popper.js-1.12.5下,找到dist/umd,将popper.min.js文件同样拷贝到js里。这里需要注意Bootstrap 4只支持umd/popper.min.js,确保使用正确的文件。

    如果需要下载Bootstrap 4文件,你可以从这里下载getbootstrap.com.

    然后拷贝bootstrap.min.jsjs文件夹里。

    所以最终的结果是:

    myproject/
     |-- myproject/
     |    |-- accounts/
     |    |-- boards/
     |    |-- myproject/
     |    |-- static/
     |    |    |-- css/
     |    |    +-- js/
     |    |         |-- bootstrap.min.js
     |    |         |-- jquery-3.2.1.min.js
     |    |         +-- popper.min.js
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    base.html文件底部,{% endblock body %}下面添加脚本文件script的引用:

    templates/base.html

    {% load static %}<!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <title>{% block title %}Django Boards{% endblock %}</title>
        <link href="https://fonts.googleapis.com/css?family=Peralta" rel="stylesheet">
        <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">
        <link rel="stylesheet" href="{% static 'css/app.css' %}">
        {% block stylesheet %}{% endblock %}
      </head>
      <body>
        {% block body %}
        <!-- code suppressed for brevity -->
        {% endblock body %}
        <script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
        <script src="{% static 'js/popper.min.js' %}"></script>
        <script src="{% static 'js/bootstrap.min.js' %}"></script>
      </body>
    </html>
    

    如果你感觉前面的说明不够清楚,那么你可以用下面的链接直接下载:

    如果没有办法下载上面的文件,你也可以从作者项目代码里直接获取得到。

    这里可以直接点击右键,选择保存。

    现在让我们添加Bootstrap 4下拉菜单吧:

    templates/base.html

    <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
      <div class="container">
        <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="mainMenu">
          <ul class="navbar-nav ml-auto">
            <li class="nav-item dropdown">
              <a class="nav-link dropdown-toggle" href="#" id="userMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                {{ user.username }}
              </a>
              <div class="dropdown-menu dropdown-menu-right" aria-labelledby="userMenu">
                <a class="dropdown-item" href="#">My account</a>
                <a class="dropdown-item" href="#">Change password</a>
                <div class="dropdown-divider"></div>
                <a class="dropdown-item" href="{% url 'logout' %}">Log out</a>
              </div>
            </li>
          </ul>
        </div>
      </div>
    </nav>
    
    下拉菜单

    让我们试一试,点击退出登录:

    退出登录

    成功了,但是下拉菜单在退出登录后依然显示着,并且用户名显示为空,让我们稍微改进一下:

    <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
      <div class="container">
        <a class="navbar-brand" href="{% url 'home' %}">Django Boards</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="mainMenu">
          {% if user.is_authenticated %}
            <ul class="navbar-nav ml-auto">
              <li class="nav-item dropdown">
                <a class="nav-link dropdown-toggle" href="#" id="userMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                  {{ user.username }}
                </a>
                <div class="dropdown-menu dropdown-menu-right" aria-labelledby="userMenu">
                  <a class="dropdown-item" href="#">My account</a>
                  <a class="dropdown-item" href="#">Change password</a>
                  <div class="dropdown-divider"></div>
                  <a class="dropdown-item" href="{% url 'logout' %}">Log out</a>
                </div>
              </li>
            </ul>
          {% else %}
            <form class="form-inline ml-auto">
              <a href="#" class="btn btn-outline-secondary">Log in</a>
              <a href="{% url 'signup' %}" class="btn btn-primary ml-2">Sign up</a>
            </form>
          {% endif %}
        </div>
      </div>
    </nav>
    

    现在这里加了一个判断,当用户未登录时,显示登录和注册按钮:


    登录页面

    要想富,先修路:

    myproject/urls.py

    <details>
    <summary>原始版本</summary>

    from django.conf.urls import url
    from django.contrib import admin
    from django.contrib.auth import views as auth_views
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        url(r'^$', views.home, name='home'),
        url(r'^signup/$', accounts_views.signup, name='signup'),
        url(r'^login/$', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
        url(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
        url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        url(r'^admin/', admin.site.urls),
    ]
    

    </details>

    <details open>
    <summary>修订版本</summary>

    from django.urls import re_path
    from django.contrib import admin
    from django.contrib.auth import views as auth_views
    
    from accounts import views as accounts_views
    from boards import views
    
    urlpatterns = [
        re_path(r'^$', views.home, name='home'),
        re_path(r'^signup/$', accounts_views.signup, name='signup'),
        re_path(r'^login/$', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
        re_path(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
        re_path(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
        re_path(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
        re_path(r'^admin/', admin.site.urls),
    ]
    

    </details>

    我们可以通过as_view()传扩展参数去覆盖默认值,这个代码里LoginView直接查找template_namelogin.html的页面.

    settings.py里添加下面的配置:

    myproject/settings.py

    LOGIN_REDIRECT_URL = 'home'
    

    这个配置会告诉Django在用户登录成功后跳转到哪个页面。

    我们还需要把登录的地址写到base.html模板里:

    templates/base.html

    <a href="{% url 'login' %}" class="btn btn-outline-secondary">Log in</a>
    

    我们创建一个login.html,并且使用注册页面的样式:

    templates/login.html

    {% extends 'base.html' %}
    
    {% load static %}
    
    {% block stylesheet %}
      <link rel="stylesheet" href="{% static 'css/accounts.css' %}">
    {% endblock %}
    
    {% block body %}
      <div class="container">
        <h1 class="text-center logo my-4">
          <a href="{% url 'home' %}">Django Boards</a>
        </h1>
        <div class="row justify-content-center">
          <div class="col-lg-4 col-md-6 col-sm-8">
            <div class="card">
              <div class="card-body">
                <h3 class="card-title">Log in</h3>
                <form method="post" novalidate>
                  {% csrf_token %}
                  {% include 'includes/form.html' %}
                  <button type="submit" class="btn btn-primary btn-block">Log in</button>
                </form>
              </div>
              <div class="card-footer text-muted text-center">
                New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
              </div>
            </div>
            <div class="text-center py-2">
              <small>
                <a href="#" class="text-muted">Forgot your password?</a>
              </small>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    

    让我们重构一下HTML,避免重复。

    创建一个账户专用base_accounts.html母模板:

    templates/base_accounts.html

    {% extends 'base.html' %}
    
    {% load static %}
    
    {% block stylesheet %}
      <link rel="stylesheet" href="{% static 'css/accounts.css' %}">
    {% endblock %}
    
    {% block body %}
      <div class="container">
        <h1 class="text-center logo my-4">
          <a href="{% url 'home' %}">Django Boards</a>
        </h1>
        {% block content %}
        {% endblock %}
      </div>
    {% endblock %}
    

    把它应用到signup.htmllogin.html里:

    templates/login.html

    {% extends 'base_accounts.html' %}
    
    {% block title %}Log in to Django Boards{% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-4 col-md-6 col-sm-8">
          <div class="card">
            <div class="card-body">
              <h3 class="card-title">Log in</h3>
              <form method="post" novalidate>
                {% csrf_token %}
                {% include 'includes/form.html' %}
                <button type="submit" class="btn btn-primary btn-block">Log in</button>
              </form>
            </div>
            <div class="card-footer text-muted text-center">
              New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
            </div>
          </div>
          <div class="text-center py-2">
            <small>
              <a href="#" class="text-muted">Forgot your password?</a>
            </small>
          </div>
        </div>
      </div>
    {% endblock %}
    

    现在我们还没有实现重置密码的页面,这里我们先写#

    templates/signup.html

    {% extends 'base_accounts.html' %}
    
    {% block title %}Sign up to Django Boards{% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-8 col-md-10 col-sm-12">
          <div class="card">
            <div class="card-body">
              <h3 class="card-title">Sign up</h3>
              <form method="post" novalidate>
                {% csrf_token %}
                {% include 'includes/form.html' %}
                <button type="submit" class="btn btn-primary btn-block">Create an account</button>
              </form>
            </div>
            <div class="card-footer text-muted text-center">
              Already have an account? <a href="{% url 'login' %}">Log in</a>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    

    注意我们增加了跳转到登录页面的标签<a href="{% url 'login' %}">Log in</a>

    表单的非字段格式校验错误

    如果我们没有填写表单信息,我们会得到下面的错误提示:

    不过如果我们提交不存在的用户名,或者无效的密码,则会变成这样:

    这里会有点误导用户,框边是绿色,并且无任何提示信息。

    这是因为表单还有另外一种特殊的错误,非字段格式校验错误non-field errors,这种错误与字段的格式无关。让我们重构form.html模板来显示这种错误:

    templates/includes/form.html

    {% load widget_tweaks %}
    
    {% if form.non_field_errors %}
      <div class="alert alert-danger" role="alert">
        {% for error in form.non_field_errors %}
          <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
        {% endfor %}
      </div>
    {% endif %}
    
    {% for field in form %}
      <!-- 未显示,别删除 -->
    {% endfor %}
    

    {% if forloop.last %}这里使用了一个小技巧。p标签有margin-bottom这个属性,而一个表单可能有多个非字段格式校验错误,这里我们就仅仅将该表单的最后一个错误显示出来,免得过多的错误提示,会扰乱页面的布局。这里Bootstrap 4 CSS类mb-0的意思就是margin bottom = 0

    我们还需要考虑密码字段,Django不会将密码返回给前端。在某些条件下,这里就先忽略is-validis-invalid这些CSS类。现在模板已经开始变得复杂起来了,让我们把一部分代码移动到template tag里吧。

    创建自定义模板标签

    boards应用目录下,创建一个新的文件夹templatetags,然后在这个文件夹下创建两个新文件__init__.pyform_tags.py

    现在我们的项目结构应该是:

    myproject/
     |-- myproject/
     |    |-- accounts/
     |    |-- boards/
     |    |    |-- migrations/
     |    |    |-- templatetags/        <-- 这里
     |    |    |    |-- __init__.py
     |    |    |    +-- form_tags.py
     |    |    |-- __init__.py
     |    |    |-- admin.py
     |    |    |-- apps.py
     |    |    |-- models.py
     |    |    |-- tests.py
     |    |    +-- views.py
     |    |-- myproject/
     |    |-- static/
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    然后我们在form_tags.py文件里创建两个标签:

    boards/templatetags/form_tags.py

    from django import template
    
    register = template.Library()
    
    @register.filter
    def field_type(bound_field):
        return bound_field.field.widget.__class__.__name__
    
    @register.filter
    def input_class(bound_field):
        css_class = ''
        if bound_field.form.is_bound:
            if bound_field.errors:
                css_class = 'is-invalid'
            elif field_type(bound_field) != 'PasswordInput':
                css_class = 'is-valid'
        return 'form-control {}'.format(css_class)
    

    这些是模板过滤器,它的工作原理是:

    首先,将它加载到模板中,就像我们使用widget_tweaksstatic模板标记一样。注意,创建此文件后,必须手动停止开发服务器并重新启动它,以便Django能够识别并加载新的模板标记。

    {% load form_tags %}
    

    这样我们就可以使用它们了:

    {{ form.username|field_type }}
    

    这样会得到:

    'TextInput'
    

    同样如果是input_class

    {{ form.username|input_class }}
    
    <!-- if the form is not bound, it will simply return: -->
    'form-control '
    
    <!-- if the form is bound and valid: -->
    'form-control is-valid'
    
    <!-- if the form is bound and invalid: -->
    'form-control is-invalid'
    

    现在我们到form.html里使用这些标签:

    templates/includes/form.html

    {% load form_tags widget_tweaks %}
    
    {% if form.non_field_errors %}
      <div class="alert alert-danger" role="alert">
        {% for error in form.non_field_errors %}
          <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
        {% endfor %}
      </div>
    {% endif %}
    
    {% for field in form %}
      <div class="form-group">
        {{ field.label_tag }}
        {% render_field field class=field|input_class %}
        {% for error in field.errors %}
          <div class="invalid-feedback">
            {{ error }}
          </div>
        {% endfor %}
        {% if field.help_text %}
          <small class="form-text text-muted">
            {{ field.help_text|safe }}
          </small>
        {% endif %}
      </div>
    {% endfor %}
    

    现在好多了吧,通过这种方式减少了大量冗余的代码,并且它还修改了密码框显示为绿色的问题:

    测试模板标签

    现在让我们重新梳理一下boards应用程序下的测试吧,就像在accounts里做的一样,创建一个tests文件夹,添加一个__init__.py文件,并且拷贝tests.py到这个文件夹里,并重命名这个文件为test_views.py

    创建一个新文件test_templatetags.py

    myproject/
     |-- myproject/
     |    |-- accounts/
     |    |-- boards/
     |    |    |-- migrations/
     |    |    |-- templatetags/
     |    |    |-- tests/
     |    |    |    |-- __init__.py
     |    |    |    |-- test_templatetags.py  <-- 新文件,空的
     |    |    |    +-- test_views.py  <-- 老文件,改名儿啦
     |    |    |-- __init__.py
     |    |    |-- admin.py
     |    |    |-- apps.py
     |    |    |-- models.py
     |    |    +-- views.py
     |    |-- myproject/
     |    |-- static/
     |    |-- templates/
     |    |-- db.sqlite3
     |    +-- manage.py
     +-- venv/
    

    调整test_views.py的引入imports

    boards/tests/test_views.py

    from ..views import home, board_topics, new_topic
    from ..models import Board, Topic, Post
    from ..forms import NewTopicForm
    

    试试执行测试用例看是否正常。

    boards/tests/test_templatetags.py

    from django import forms
    from django.test import TestCase
    from ..templatetags.form_tags import field_type, input_class
    
    class ExampleForm(forms.Form):
        name = forms.CharField()
        password = forms.CharField(widget=forms.PasswordInput())
        class Meta:
            fields = ('name', 'password')
    
    class FieldTypeTests(TestCase):
        def test_field_widget_type(self):
            form = ExampleForm()
            self.assertEquals('TextInput', field_type(form['name']))
            self.assertEquals('PasswordInput', field_type(form['password']))
    
    class InputClassTests(TestCase):
        def test_unbound_field_initial_state(self):
            form = ExampleForm()  # unbound form
            self.assertEquals('form-control ', input_class(form['name']))
    
        def test_valid_bound_field(self):
            form = ExampleForm({'name': 'john', 'password': '123'})  # bound form (field + data)
            self.assertEquals('form-control is-valid', input_class(form['name']))
            self.assertEquals('form-control ', input_class(form['password']))
    
        def test_invalid_bound_field(self):
            form = ExampleForm({'name': '', 'password': '123'})  # bound form (field + data)
            self.assertEquals('form-control is-invalid', input_class(form['name']))
    

    这里我们为测试类创建了表单实例来配合进行测试。

    python manage.py test
    
    Creating test database for alias 'default'...
    System check identified no issues (0 silenced).
    ................................
    ----------------------------------------------------------------------
    Ran 32 tests in 0.846s
    
    OK
    Destroying test database for alias 'default'...
    

    重置密码

    重置密码的流程需要匹配一些比较复杂的URL路由,这个我们在前一个教程里已经讨论过了,这里不需要精通正则表达式,所以只需要知道常用的就好了。

    另外一个重点是在重置密码流程里,需要我们发送一个包含重置链接的电子邮件。这个功能在初学时可能会比较困难,因为它可能需要用到其他第三方的服务。不过现在我们不需要部署产品级别的邮件服务,只需要使用Django的调试工具就可以检测到这个邮件是否正常发送。

    Email 控制台后端

    在项目的开发过程中,我们不发送真正的电子邮件,而是记录下来。这里有两个选择:在文本文件中写入所有电子邮件,或只是在控制台中显示它们。实际上后一个选项更方便,因为我们已经在使用一个控制台来运行开发服务器,而且设置也更容易一些。

    编辑settings.py文件,添加EMAIL_BACKEND到文档的底部:

    myproject/settings.py

    EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
    
    配置路由

    密码重置流程需要4个页面:

    • 一个表单页面来开始进行密码重置;
    • 一个邮件发送成功页面来提示用户查看电子邮件;
    • 一个页面来验证用户收到的验证码是否有效;
    • 一个页面来告诉用户重置密码成功或者失败。

    这些页面Django都有内置模板,我们只需要配置urls.py并且创建模板就可以了。

    myproject/urls.py(完整原始版本文件查看)

    <details>
    <summary>原始版本</summary>

    url(r'^reset/$',
        auth_views.PasswordResetView.as_view(
            template_name='password_reset.html',
            email_template_name='password_reset_email.html',
            subject_template_name='password_reset_subject.txt'
        ),
        name='password_reset'),
    url(r'^reset/done/$',
        auth_views.PasswordResetDoneView.as_view(template_name='password_reset_done.html'),
        name='password_reset_done'),
    url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
        auth_views.PasswordResetConfirmView.as_view(template_name='password_reset_confirm.html'),
        name='password_reset_confirm'),
    url(r'^reset/complete/$',
        auth_views.PasswordResetCompleteView.as_view(template_name='password_reset_complete.html'),
        name='password_reset_complete'),
    ]
    

    </details>

    <details open>
    <summary>修订版本</summary>

    re_path(r'^reset/$',
        auth_views.PasswordResetView.as_view(
            template_name='password_reset.html',
            email_template_name='password_reset_email.html',
            subject_template_name='password_reset_subject.txt'
        ),
        name='password_reset'),
    re_path(r'^reset/done/$',
        auth_views.PasswordResetDoneView.as_view(template_name='password_reset_done.html'),
        name='password_reset_done'),
    re_path(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
        auth_views.PasswordResetConfirmView.as_view(template_name='password_reset_confirm.html'),
        name='password_reset_confirm'),
    re_path(r'^reset/complete/$',
        auth_views.PasswordResetCompleteView.as_view(template_name='password_reset_complete.html'),
        name='password_reset_complete'),
    ]
    

    </details>

    template_name这个参数是可选参数,不过我还是建议配置上,这样可以比默认值更加清晰明了。

    在模板目录templates下,有以下这些模板:

    • password_reset.html
    • password_reset_email.html: 这是发送给用户的邮件内容
    • password_reset_subject.txt: 这是发送给用户的邮件标题,应当只有一行文字
    • password_reset_done.html
    • password_reset_confirm.html
    • password_reset_complete.html

    在实现这些文件前,让我们先准备好测试代码文件。

    只需要一些简单的测试来测试我们自己应用程序的功能,其他已经在Django自己的代码中做好了测试。

    accounts/tests中创建一个新的测试文件test_view_password_reset.py

    密码重置页面
    • templates/password_reset.html
    {% extends 'base_accounts.html' %}
    
    {% block title %}Reset your password{% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-4 col-md-6 col-sm-8">
          <div class="card">
            <div class="card-body">
              <h3 class="card-title">Reset your password</h3>
              <p>Enter your email address and we will send you a link to reset your password.</p>
              <form method="post" novalidate>
                {% csrf_token %}
                {% include 'includes/form.html' %}
                <button type="submit" class="btn btn-primary btn-block">Send password reset email</button>
              </form>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    
    • accounts/tests/test_view_password_reset.py
    from django.contrib.auth import views as auth_views
    from django.contrib.auth.forms import PasswordResetForm
    from django.contrib.auth.models import User
    from django.core import mail
    # from django.core.urlresolvers import reverse
    from django.urls import resolve, reverse
    from django.test import TestCase
    
    class PasswordResetTests(TestCase):
        def setUp(self):
            url = reverse('password_reset')
            self.response = self.client.get(url)
    
        def test_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_view_function(self):
            view = resolve('/reset/')
            self.assertEquals(view.func.view_class, auth_views.PasswordResetView)
    
        def test_csrf(self):
            self.assertContains(self.response, 'csrfmiddlewaretoken')
    
        def test_contains_form(self):
            form = self.response.context.get('form')
            self.assertIsInstance(form, PasswordResetForm)
    
        def test_form_inputs(self):
            '''
            The view must contain two inputs: csrf and email
            '''
            self.assertContains(self.response, '<input', 2)
            self.assertContains(self.response, 'type="email"', 1)
    
    class SuccessfulPasswordResetTests(TestCase):
        def setUp(self):
            email = 'john@doe.com'
            User.objects.create_user(username='john', email=email, password='123abcdef')
            url = reverse('password_reset')
            self.response = self.client.post(url, {'email': email})
    
        def test_redirection(self):
            '''
            A valid form submission should redirect the user to `password_reset_done` view
            '''
            url = reverse('password_reset_done')
            self.assertRedirects(self.response, url)
    
        def test_send_password_reset_email(self):
            self.assertEqual(1, len(mail.outbox))
    
    class InvalidPasswordResetTests(TestCase):
        def setUp(self):
            url = reverse('password_reset')
            self.response = self.client.post(url, {'email': 'donotexist@email.com'})
    
        def test_redirection(self):
            '''
            Even invalid emails in the database should
            redirect the user to `password_reset_done` view
            '''
            url = reverse('password_reset_done')
            self.assertRedirects(self.response, url)
    
        def test_no_reset_email_sent(self):
            self.assertEqual(0, len(mail.outbox))
    
    • templates/password_reset_subject.txt
    [Django Boards] Please reset your password
    
    • templates/password_reset_email.html
    Hi there,
    
    Someone asked for a password reset for the email address {{ email }}.
    Follow the link below:
    {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
    
    In case you forgot your Django Boards username: {{ user.username }}
    
    If clicking the link above doesn't work, please copy and paste the URL
    in a new browser window instead.
    
    If you've received this mail in error, it's likely that another user entered
    your email address by mistake while trying to reset a password. If you didn't
    initiate the request, you don't need to take any further action and can safely
    disregard this email.
    
    Thanks,
    
    The Django Boards Team
    

    让我们在accounts/tests里创建一个测试文件来专门测试邮件内容test_mail_password_reset.py

    • accounts/tests/test_mail_password_reset.py
    from django.core import mail
    from django.contrib.auth.models import User
    from django.urls import reverse
    from django.test import TestCase
    
    class PasswordResetMailTests(TestCase):
        def setUp(self):
            User.objects.create_user(username='john', email='john@doe.com', password='123')
            self.response = self.client.post(reverse('password_reset'), { 'email': 'john@doe.com' })
            self.email = mail.outbox[0]
    
        def test_email_subject(self):
            self.assertEqual('[Django Boards] Please reset your password', self.email.subject)
    
        def test_email_body(self):
            context = self.response.context
            token = context.get('token')
            uid = context.get('uid')
            password_reset_token_url = reverse('password_reset_confirm', kwargs={
                'uidb64': uid,
                'token': token
            })
            self.assertIn(password_reset_token_url, self.email.body)
            self.assertIn('john', self.email.body)
            self.assertIn('john@doe.com', self.email.body)
    
        def test_email_to(self):
            self.assertEqual(['john@doe.com',], self.email.to)
    

    这个测试用例会抓取应用程序发送的电子邮件,检查邮件的标题、主要邮件内容以及发送目标是否正确。

    密码重置成功页面
    • templates/password_reset_done.html
    {% extends 'base_accounts.html' %}
    
    {% block title %}Reset your password{% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-4 col-md-6 col-sm-8">
          <div class="card">
            <div class="card-body">
              <h3 class="card-title">Reset your password</h3>
              <p>Check your email for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.</p>
              <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    
    • accounts/tests/test_view_password_reset.py
    from django.contrib.auth import views as auth_views
    #from django.core.urlresolvers import reverse
    from django.urls import resolve, reverse
    from django.test import TestCase
    
    class PasswordResetDoneTests(TestCase):
        def setUp(self):
            url = reverse('password_reset_done')
            self.response = self.client.get(url)
    
        def test_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_view_function(self):
            view = resolve('/reset/done/')
            self.assertEquals(view.func.view_class, auth_views.PasswordResetDoneView)
    
    密码重置确认页面
    • templates/password_reset_confirm.html
    {% extends 'base_accounts.html' %}
    
    {% block title %}
      {% if validlink %}
        Change password for {{ form.user.username }}
      {% else %}
        Reset your password
      {% endif %}
    {% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-6 col-md-8 col-sm-10">
          <div class="card">
            <div class="card-body">
              {% if validlink %}
                <h3 class="card-title">Change password for @{{ form.user.username }}</h3>
                <form method="post" novalidate>
                  {% csrf_token %}
                  {% include 'includes/form.html' %}
                  <button type="submit" class="btn btn-success btn-block">Change password</button>
                </form>
              {% else %}
                <h3 class="card-title">Reset your password</h3>
                <div class="alert alert-danger" role="alert">
                  It looks like you clicked on an invalid password reset link. Please try again.
                </div>
                <a href="{% url 'password_reset' %}" class="btn btn-secondary btn-block">Request a new password reset link</a>
              {% endif %}
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    

    这个页面只能通过邮件里发送的链接访问到,类似这样的链接http://127.0.0.1:8000/reset/Mw/4po-2b5f2d47c19966e294a1/

    在开发阶段,可以从控制台获取到这个链接。

    如果这个链接是有效的,就可以访问到下面的页面:

    或者这个链接已经失效了:

    • accounts/tests/test_view_password_reset.py
    from django.contrib.auth.tokens import default_token_generator
    from django.utils.encoding import force_bytes
    from django.utils.http import urlsafe_base64_encode
    from django.contrib.auth import views as auth_views
    from django.contrib.auth.forms import SetPasswordForm
    from django.contrib.auth.models import User
    #from django.core.urlresolvers import reverse
    from django.urls import resolve, reverse
    from django.test import TestCase
    
    class PasswordResetConfirmTests(TestCase):
        def setUp(self):
            user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
    
            '''
            create a valid password reset token
            based on how django creates the token internally:
            https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280
            '''
            self.uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
            self.token = default_token_generator.make_token(user)
    
            url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token})
            self.response = self.client.get(url, follow=True)
    
        def test_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_view_function(self):
            view = resolve('/reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token))
            self.assertEquals(view.func.view_class, auth_views.PasswordResetConfirmView)
    
        def test_csrf(self):
            self.assertContains(self.response, 'csrfmiddlewaretoken')
    
        def test_contains_form(self):
            form = self.response.context.get('form')
            self.assertIsInstance(form, SetPasswordForm)
    
        def test_form_inputs(self):
            '''
            The view must contain two inputs: csrf and two password fields
            '''
            self.assertContains(self.response, '<input', 3)
            self.assertContains(self.response, 'type="password"', 2)
    
    class InvalidPasswordResetConfirmTests(TestCase):
        def setUp(self):
            user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
            uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
            token = default_token_generator.make_token(user)
    
            '''
            invalidate the token by changing the password
            '''
            user.set_password('abcdef123')
            user.save()
    
            url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
            self.response = self.client.get(url)
    
        def test_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_html(self):
            password_reset_url = reverse('password_reset')
            self.assertContains(self.response, 'invalid password reset link')
            self.assertContains(self.response, 'href="{0}"'.format(password_reset_url))
    
    密码重置成功页面
    • templates/password_reset_complete.html
    {% extends 'base_accounts.html' %}
    
    {% block title %}Password changed!{% endblock %}
    
    {% block content %}
      <div class="row justify-content-center">
        <div class="col-lg-6 col-md-8 col-sm-10">
          <div class="card">
            <div class="card-body">
              <h3 class="card-title">Password changed!</h3>
              <div class="alert alert-success" role="alert">
                You have successfully changed your password! You may now proceed to log in.
              </div>
              <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
            </div>
          </div>
        </div>
      </div>
    {% endblock %}
    
    from django.contrib.auth import views as auth_views
    #from django.core.urlresolvers import reverse
    from django.urls import resolve, reverse
    from django.test import TestCase
    
    class PasswordResetCompleteTests(TestCase):
        def setUp(self):
            url = reverse('password_reset_complete')
            self.response = self.client.get(url)
    
        def test_status_code(self):
            self.assertEquals(self.response.status_code, 200)
    
        def test_view_function(self):
            view = resolve('/reset/complete/')
            self.assertEquals(view.func.view_class, auth_views.PasswordResetCompleteView)
    

    修改密码页面

    这个页面是登录后的用户用来修改密码的,一般来说,它的表单包含3个字段,旧密码、新密码和新密码确认。

    myproject/urls.py(完整原始文件下载)

    <details>
    <summary>原始版本</summary>

    url(r'^settings/password/$', auth_views.PasswordChangeView.as_view(template_name='password_change.html'),
        name='password_change'),
    url(r'^settings/password/done/$', auth_views.PasswordChangeDoneView.as_view(template_name='password_change_done.html'),
        name='password_change_done'),
    

    </details>

    <details open>
    <summary>修订版本</summary>

    re_path(r'^settings/password/$', auth_views.PasswordChangeView.as_view(template_name='password_change.html'),
        name='password_change'),
    re_path(r'^settings/password/done/$', auth_views.PasswordChangeDoneView.as_view(template_name='password_change_done.html'),
        name='password_change_done'),
    

    </details>

    这些页面只有登录的用户才能访问,所以添加了@login_required,这个装饰器会阻止未授权用户访问,如果未授权用户直接访问,Django会重定位到登录页面。

    让我们再把登录路由配置到项目设置settings.py里:

    myproject/settings.py (完整原始文件下载)

    LOGIN_URL = 'login'
    
    • templates/password_change.html
    {% extends 'base.html' %}
    
    {% block title %}Change password{% endblock %}
    
    {% block breadcrumb %}
      <li class="breadcrumb-item active">Change password</li>
    {% endblock %}
    
    {% block content %}
      <div class="row">
        <div class="col-lg-6 col-md-8 col-sm-10">
          <form method="post" novalidate>
            {% csrf_token %}
            {% include 'includes/form.html' %}
            <button type="submit" class="btn btn-success">Change password</button>
          </form>
        </div>
      </div>
    {% endblock %}
    
    • templates/password_change_done.html
    {% extends 'base.html' %}
    
    {% block title %}Change password successful{% endblock %}
    
    {% block breadcrumb %}
      <li class="breadcrumb-item"><a href="{% url 'password_change' %}">Change password</a></li>
      <li class="breadcrumb-item active">Success</li>
    {% endblock %}
    
    {% block content %}
      <div class="alert alert-success" role="alert">
        <strong>Success!</strong> Your password has been changed!
      </div>
      <a href="{% url 'home' %}" class="btn btn-secondary">Return to home page</a>
    {% endblock %}
    

    让我们为修改密码页面添加测试用例,创建测试文件test_view_password_change.py

    我将在下面列出新增的测试用例,你可以单击代码段旁边的完整原始文件下载链接,查看我为密码更改视图编写的所有测试。大多数测试与我们目前所做的相似,我只是移到了外部以避免重复。

    accounts/tests/test_view_password_change.py (完整原始文件下载)

    class LoginRequiredPasswordChangeTests(TestCase):
        def test_redirection(self):
            url = reverse('password_change')
            login_url = reverse('login')
            response = self.client.get(url)
            self.assertRedirects(response, f'{login_url}?next={url}')
    

    上面的测试用例尝试在未登录时访问页面password_change,预期的结果是会重定位到登录页面。

    class PasswordChangeTestCase(TestCase):
        def setUp(self, data={}):
            self.user = User.objects.create_user(username='john', email='john@doe.com', password='old_password')
            self.url = reverse('password_change')
            self.client.login(username='john', password='old_password')
            self.response = self.client.post(self.url, data)
    

    这里我们定义了一个名为PasswordChangeTestCase的新测试类。它创建了一个用户,并向password_change页面方法发出POST请求。在下一组测试用例中,我们将使用这个类而不是TestCase类作为父类,去测试成功的请求和无效的请求:

    class SuccessfulPasswordChangeTests(PasswordChangeTestCase):
        def setUp(self):
            super().setUp({
                'old_password': 'old_password',
                'new_password1': 'new_password',
                'new_password2': 'new_password',
            })
    
        def test_redirection(self):
            '''
            A valid form submission should redirect the user
            '''
            self.assertRedirects(self.response, reverse('password_change_done'))
    
        def test_password_changed(self):
            '''
            refresh the user instance from database to get the new password
            hash updated by the change password view.
            '''
            self.user.refresh_from_db()
            self.assertTrue(self.user.check_password('new_password'))
    
        def test_user_authentication(self):
            '''
            Create a new request to an arbitrary page.
            The resulting response should now have an `user` to its context, after a successful sign up.
            '''
            response = self.client.get(reverse('home'))
            user = response.context.get('user')
            self.assertTrue(user.is_authenticated)
    
    class InvalidPasswordChangeTests(PasswordChangeTestCase):
        def test_status_code(self):
            '''
            An invalid form submission should return to the same page
            '''
            self.assertEquals(self.response.status_code, 200)
    
        def test_form_errors(self):
            form = self.response.context.get('form')
            self.assertTrue(form.errors)
    
        def test_didnt_change_password(self):
            '''
            refresh the user instance from the database to make
            sure we have the latest data.
            '''
            self.user.refresh_from_db()
            self.assertTrue(self.user.check_password('old_password'))
    

    refresh_from_db()这个方法会获取数据库的最新数据,它会让Django强制查询来更新数据,这里因为用户修改了密码保存到数据库,所以必须要刷新一次数据才能准确的测试用户的密码是否修改成功。


    小结

    身份验证对于大多数Django应用程序来说是一个非常常见的用例。在本教程中,我们实现了所有重要页面:注册、登录、注销、密码重置和更改密码。现在我们已经有了一种方式来创建用户并对它们进行身份验证,这样我们就能够继续开发应用程序的其他页面。

    我们仍然需要改进代码设计的许多地方:模板文件夹开始变得越来越混乱,文件太多。boards应用程序测试仍然没有梳理。另外,必须开始重构新的主题页面,因为现在我们可以判断用户是否登录。

    项目的源代码可在GitHub上使用。项目的当前状态可在发布标签v0.4-lw下找到。可以通过下面的链接访问:

    https://github.com/sibtc/django-beginners-guide/tree/v0.4-lw

    上一节:Django初学者入门指南3-高级概念(译&改)

    下一节:Django初学者入门指南5-存储数据(译&改)

    相关文章

      网友评论

          本文标题:Django初学者入门指南4-登录认证(译&改)

          本文链接:https://www.haomeiwen.com/subject/jxowyktx.html