Django入门与实践-第16章:用户登录

首先,添加一个新的URL路径: {% raw %} myproject/urls.py

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

as_view()中,我们可以传递一些额外的参数,以覆盖默认值。在这种情况下,我们让LoginView 使用login.html模板。

编辑settings.py然后添加

myproject/settings.py

  1. LOGIN_REDIRECT_URL = 'home'

这个配置信息告诉Django在成功登录后将用户重定向到哪里。

最后,将登录URL添加到 base.html模板中:

templates/base.html

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

我们可以创建一个类似于注册页面的模板。创建一个名为 login.html 的新文件:

templates/login.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-4 col-md-6 col-sm-8">
  13. <div class="card">
  14. <div class="card-body">
  15. <h3 class="card-title">Log in</h3>
  16. <form method="post" novalidate>
  17. {% csrf_token %}
  18. {% include 'includes/form.html' %}
  19. <button type="submit" class="btn btn-primary btn-block">Log in</button>
  20. </form>
  21. </div>
  22. <div class="card-footer text-muted text-center">
  23. New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
  24. </div>
  25. </div>
  26. <div class="text-center py-2">
  27. <small>
  28. <a href="#" class="text-muted">Forgot your password?</a>
  29. </small>
  30. </div>
  31. </div>
  32. </div>
  33. </div>
  34. {% endblock %}

Login

我们看到HTML模板中的内容重复了,现在来重构一下它。

创建一个名为base_accounts.html的新模板:

templates/base_accounts.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. {% block content %}
  12. {% endblock %}
  13. </div>
  14. {% endblock %}

现在在signup.htmllogin.html中使用它:

templates/login.html

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}Log in to Django Boards{% endblock %}
  3. {% block content %}
  4. <div class="row justify-content-center">
  5. <div class="col-lg-4 col-md-6 col-sm-8">
  6. <div class="card">
  7. <div class="card-body">
  8. <h3 class="card-title">Log in</h3>
  9. <form method="post" novalidate>
  10. {% csrf_token %}
  11. {% include 'includes/form.html' %}
  12. <button type="submit" class="btn btn-primary btn-block">Log in</button>
  13. </form>
  14. </div>
  15. <div class="card-footer text-muted text-center">
  16. New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
  17. </div>
  18. </div>
  19. <div class="text-center py-2">
  20. <small>
  21. <a href="#" class="text-muted">Forgot your password?</a>
  22. </small>
  23. </div>
  24. </div>
  25. </div>
  26. {% endblock %}

我们有密码重置的功能,因此现在让我们将其暂时保留为#

templates/signup.html

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}Sign up to Django Boards{% endblock %}
  3. {% block content %}
  4. <div class="row justify-content-center">
  5. <div class="col-lg-8 col-md-10 col-sm-12">
  6. <div class="card">
  7. <div class="card-body">
  8. <h3 class="card-title">Sign up</h3>
  9. <form method="post" novalidate>
  10. {% csrf_token %}
  11. {% include 'includes/form.html' %}
  12. <button type="submit" class="btn btn-primary btn-block">Create an account</button>
  13. </form>
  14. </div>
  15. <div class="card-footer text-muted text-center">
  16. Already have an account? <a href="{% url 'login' %}">Log in</a>
  17. </div>
  18. </div>
  19. </div>
  20. </div>
  21. {% endblock %}

请注意,我们添加了登录链接: <a href="{% url 'login' %}">Log in</a>.

无登录信息错误

如果我们提交空白的登录信息,我们会得到一些友好的错误提示信息:

Login

但是,如果我们提交一个不存在的用户名或一个无效的密码,现在就会发生这种情况:

Login

有点误导,这个区域是绿色的,表明它们是良好运行的,此外,没有其他额外的信息。

这是因为表单有一种特殊类型的错误,叫做 non-field errors。这是一组与特定字段无关的错误。让我们重构form.html部分模板以显示这些错误:

templates/includes/form.html

  1. {% load widget_tweaks %}
  2. {% if form.non_field_errors %}
  3. <div class="alert alert-danger" role="alert">
  4. {% for error in form.non_field_errors %}
  5. <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
  6. {% endfor %}
  7. </div>
  8. {% endif %}
  9. {% for field in form %}
  10. <!-- code suppressed -->
  11. {% endfor %}

{% if forloop.last %}只是一个小事情,因为p标签有一个空白的margin-bottom.一个表单可能有几个non-field error,我们呈现了一个带有错误的p标签。然后我要检查它是否是最后一次渲染的错误。如果是这样的,我们就添加一个 Bootstrap 4 CSS类 mb-0 ,它的作用是代表了“margin bottom = 0”(底部边缘为0)。这样的话警告看起来就不那么奇怪了并且多了一些额外的空间。这只是一个非常小的细节。我这么做的原因只是为了保持间距的一致性。

Login

尽管如此,我们仍然需要处理密码字段。问题在于,Django从不将密码字段的数据返回给客户端。因此,在某些情况下,不要试图做一次自作聪明的事情,我们可以直接忽略is-validis-invalid 的CSS类。但是我们的表单模板看起来十分的复杂,我们可以将一些代码移动到模板标记中去。

创建自定义模板标签

boards应用中,创建一个名为templatetags的新文件夹。然后在该文件夹内创建两个名为 init.pyform_tags.py的空文件。

文件结构应该如下:

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

form_tags.py文件中,我们创建两个模板标签:

boards/templatetags/form_tags.py

  1. from django import template
  2. register = template.Library()
  3. @register.filter
  4. def field_type(bound_field):
  5. return bound_field.field.widget.__class__.__name__
  6. @register.filter
  7. def input_class(bound_field):
  8. css_class = ''
  9. if bound_field.form.is_bound:
  10. if bound_field.errors:
  11. css_class = 'is-invalid'
  12. elif field_type(bound_field) != 'PasswordInput':
  13. css_class = 'is-valid'
  14. return 'form-control {}'.format(css_class)

这些是模板过滤器,他们的工作方式是这样的:

首先,我们将它加载到模板中,就像我们使用 widget_tweaksstatic 模板标签一样。请注意,在创建这个文件后,你将不得不手动停止开发服务器并重启它,以便Django可以识别新的模板标签。

  1. {% load form_tags %}

之后,我们就可以在模板中使用它们了。

  1. {{ form.username|field_type }}

返回:

  1. 'TextInput'

或者在 input_class的情况下:

  1. {{ form.username|input_class }}
  2. <!-- if the form is not bound, it will simply return: -->
  3. 'form-control '
  4. <!-- if the form is bound and valid: -->
  5. 'form-control is-valid'
  6. <!-- if the form is bound and invalid: -->
  7. 'form-control is-invalid'

现在更新 form.html以使用新的模板标签:

templates/includes/form.html

  1. {% load form_tags widget_tweaks %}
  2. {% if form.non_field_errors %}
  3. <div class="alert alert-danger" role="alert">
  4. {% for error in form.non_field_errors %}
  5. <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
  6. {% endfor %}
  7. </div>
  8. {% endif %}
  9. {% for field in form %}
  10. <div class="form-group">
  11. {{ field.label_tag }}
  12. {% render_field field class=field|input_class %}
  13. {% for error in field.errors %}
  14. <div class="invalid-feedback">
  15. {{ error }}
  16. </div>
  17. {% endfor %}
  18. {% if field.help_text %}
  19. <small class="form-text text-muted">
  20. {{ field.help_text|safe }}
  21. </small>
  22. {% endif %}
  23. </div>
  24. {% endfor %}

这样的话就好多了是吧?这样做降低了模板的复杂性,它现在看起来更加整洁。并且它还解决了密码字段显示绿色边框的问题:

Login

测试模板标签

首先,让我们稍微组织一下boards的测试。就像我们对account app 所做的那样。创建一个新的文件夹名为tests,添加一个init.py,复制test.py并且将其重命名为test_views.py

添加一个名为 test_templatetags.py的新空文件。

  1. myproject/
  2. |-- myproject/
  3. | |-- accounts/
  4. | |-- boards/
  5. | | |-- migrations/
  6. | | |-- templatetags/
  7. | | |-- tests/
  8. | | | |-- __init__.py
  9. | | | |-- test_templatetags.py <-- new file, empty for now
  10. | | | +-- test_views.py <-- our old file with all the tests
  11. | | |-- __init__.py
  12. | | |-- admin.py
  13. | | |-- apps.py
  14. | | |-- models.py
  15. | | +-- views.py
  16. | |-- myproject/
  17. | |-- static/
  18. | |-- templates/
  19. | |-- db.sqlite3
  20. | +-- manage.py
  21. +-- venv/

修复test_views.py的导入问题:

boards/tests/test_views.py

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

执行测试来确保一切都正常。

boards/tests/test_templatetags.py

  1. from django import forms
  2. from django.test import TestCase
  3. from ..templatetags.form_tags import field_type, input_class
  4. class ExampleForm(forms.Form):
  5. name = forms.CharField()
  6. password = forms.CharField(widget=forms.PasswordInput())
  7. class Meta:
  8. fields = ('name', 'password')
  9. class FieldTypeTests(TestCase):
  10. def test_field_widget_type(self):
  11. form = ExampleForm()
  12. self.assertEquals('TextInput', field_type(form['name']))
  13. self.assertEquals('PasswordInput', field_type(form['password']))
  14. class InputClassTests(TestCase):
  15. def test_unbound_field_initial_state(self):
  16. form = ExampleForm() # unbound form
  17. self.assertEquals('form-control ', input_class(form['name']))
  18. def test_valid_bound_field(self):
  19. form = ExampleForm({'name': 'john', 'password': '123'}) # bound form (field + data)
  20. self.assertEquals('form-control is-valid', input_class(form['name']))
  21. self.assertEquals('form-control ', input_class(form['password']))
  22. def test_invalid_bound_field(self):
  23. form = ExampleForm({'name': '', 'password': '123'}) # bound form (field + data)
  24. self.assertEquals('form-control is-invalid', input_class(form['name']))

我们创建了一个用于测试的表单类,然后添加了覆盖两个模板标记中可能出现的场景的测试用例。

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

密码重置

密码重置过程中涉及一些不友好的 URL 模式。但正如我们在前面的教程中讨论的那样,我们并不需要成为正则表达式专家。我们只需要了解常见问题和它们的解决办法。

在我们开始之前另一件重要的事情是,对于密码重置过程,我们需要发送电子邮件。一开始有点复杂,因为我们需要外部服务。目前,我们不会配置生产环境使用的电子邮件服务。实际上,在开发阶段,我们可以使用Django的调试工具检查电子邮件是否正确发送。

Django入门与实践-第16章:用户登录 - 图6

控制台收发Email

这个主意来自于项目开发过程中,而不是发送真实的电子邮件,我们只需要记录它们。我们有两种选择:将所有电子邮件写入文本文件或仅将其显示在控制台中。我发现第二个方式更加方便,因为我们已经在使用控制台来运行开发服务器,并且设置更容易一些。

编辑 settings.py模块并将EMAIL_BACKEND变量添加到文件的末尾。

myproject/settings.py

  1. EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

配置路由

密码重置过程需要四个视图:

  • 带有表单的页面,用于启动重置过程;
  • 一个成功的页面,表示该过程已启动,指示用户检查其邮件文件夹等;
  • 检查通过电子邮件发送token的页面
  • 一个告诉用户重置是否成功的页面

这些视图是内置的,我们不需要执行任何操作,我们所需要做的就是将路径添加到 urls.py并且创建模板。

myproject/urls.py (完整代码)

  1. url(r'^reset/$',
  2. auth_views.PasswordResetView.as_view(
  3. template_name='password_reset.html',
  4. email_template_name='password_reset_email.html',
  5. subject_template_name='password_reset_subject.txt'
  6. ),
  7. name='password_reset'),
  8. url(r'^reset/done/$',
  9. auth_views.PasswordResetDoneView.as_view(template_name='password_reset_done.html'),
  10. name='password_reset_done'),
  11. url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
  12. auth_views.PasswordResetConfirmView.as_view(template_name='password_reset_confirm.html'),
  13. name='password_reset_confirm'),
  14. url(r'^reset/complete/$',
  15. auth_views.PasswordResetCompleteView.as_view(template_name='password_reset_complete.html'),
  16. name='password_reset_complete'),
  17. ]

在密码重置视图中,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

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}Reset your password{% endblock %}
  3. {% block content %}
  4. <div class="row justify-content-center">
  5. <div class="col-lg-4 col-md-6 col-sm-8">
  6. <div class="card">
  7. <div class="card-body">
  8. <h3 class="card-title">Reset your password</h3>
  9. <p>Enter your email address and we will send you a link to reset your password.</p>
  10. <form method="post" novalidate>
  11. {% csrf_token %}
  12. {% include 'includes/form.html' %}
  13. <button type="submit" class="btn btn-primary btn-block">Send password reset email</button>
  14. </form>
  15. </div>
  16. </div>
  17. </div>
  18. </div>
  19. {% endblock %}

Password Reset

accounts/tests/test_view_password_reset.py

  1. from django.contrib.auth import views as auth_views
  2. from django.contrib.auth.forms import PasswordResetForm
  3. from django.contrib.auth.models import User
  4. from django.core import mail
  5. from django.core.urlresolvers import reverse
  6. from django.urls import resolve
  7. from django.test import TestCase
  8. class PasswordResetTests(TestCase):
  9. def setUp(self):
  10. url = reverse('password_reset')
  11. self.response = self.client.get(url)
  12. def test_status_code(self):
  13. self.assertEquals(self.response.status_code, 200)
  14. def test_view_function(self):
  15. view = resolve('/reset/')
  16. self.assertEquals(view.func.view_class, auth_views.PasswordResetView)
  17. def test_csrf(self):
  18. self.assertContains(self.response, 'csrfmiddlewaretoken')
  19. def test_contains_form(self):
  20. form = self.response.context.get('form')
  21. self.assertIsInstance(form, PasswordResetForm)
  22. def test_form_inputs(self):
  23. '''
  24. The view must contain two inputs: csrf and email
  25. '''
  26. self.assertContains(self.response, '<input', 2)
  27. self.assertContains(self.response, 'type="email"', 1)
  28. class SuccessfulPasswordResetTests(TestCase):
  29. def setUp(self):
  30. email = 'john@doe.com'
  31. User.objects.create_user(username='john', email=email, password='123abcdef')
  32. url = reverse('password_reset')
  33. self.response = self.client.post(url, {'email': email})
  34. def test_redirection(self):
  35. '''
  36. A valid form submission should redirect the user to `password_reset_done` view
  37. '''
  38. url = reverse('password_reset_done')
  39. self.assertRedirects(self.response, url)
  40. def test_send_password_reset_email(self):
  41. self.assertEqual(1, len(mail.outbox))
  42. class InvalidPasswordResetTests(TestCase):
  43. def setUp(self):
  44. url = reverse('password_reset')
  45. self.response = self.client.post(url, {'email': 'donotexist@email.com'})
  46. def test_redirection(self):
  47. '''
  48. Even invalid emails in the database should
  49. redirect the user to `password_reset_done` view
  50. '''
  51. url = reverse('password_reset_done')
  52. self.assertRedirects(self.response, url)
  53. def test_no_reset_email_sent(self):
  54. self.assertEqual(0, len(mail.outbox))

templates/password_reset_subject.txt

  1. [Django Boards] Please reset your password

templates/password_reset_email.html

  1. Hi there,
  2. Someone asked for a password reset for the email address {{ email }}.
  3. Follow the link below:
  4. {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
  5. In case you forgot your Django Boards username: {{ user.username }}
  6. If clicking the link above doesn't work, please copy and paste the URL
  7. in a new browser window instead.
  8. If you've received this mail in error, it's likely that another user entered
  9. your email address by mistake while trying to reset a password. If you didn't
  10. initiate the request, you don't need to take any further action and can safely
  11. disregard this email.
  12. Thanks,
  13. The Django Boards Team

Password Reset Email

我们可以创建一个特定的文件来测试电子邮件。在accounts/tests 文件夹中创建一个名为test_mail_password_reset.py的新文件:

accounts/tests/test_mail_password_reset.py

  1. from django.core import mail
  2. from django.contrib.auth.models import User
  3. from django.urls import reverse
  4. from django.test import TestCase
  5. class PasswordResetMailTests(TestCase):
  6. def setUp(self):
  7. User.objects.create_user(username='john', email='john@doe.com', password='123')
  8. self.response = self.client.post(reverse('password_reset'), { 'email': 'john@doe.com' })
  9. self.email = mail.outbox[0]
  10. def test_email_subject(self):
  11. self.assertEqual('[Django Boards] Please reset your password', self.email.subject)
  12. def test_email_body(self):
  13. context = self.response.context
  14. token = context.get('token')
  15. uid = context.get('uid')
  16. password_reset_token_url = reverse('password_reset_confirm', kwargs={
  17. 'uidb64': uid,
  18. 'token': token
  19. })
  20. self.assertIn(password_reset_token_url, self.email.body)
  21. self.assertIn('john', self.email.body)
  22. self.assertIn('john@doe.com', self.email.body)
  23. def test_email_to(self):
  24. self.assertEqual(['john@doe.com',], self.email.to)

此测试用例抓取应用程序发送的电子邮件,并检查主题行,正文内容以及发送给谁。

密码重置完成视图

templates/password_reset_done.html

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}Reset your password{% endblock %}
  3. {% block content %}
  4. <div class="row justify-content-center">
  5. <div class="col-lg-4 col-md-6 col-sm-8">
  6. <div class="card">
  7. <div class="card-body">
  8. <h3 class="card-title">Reset your password</h3>
  9. <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>
  10. <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
  11. </div>
  12. </div>
  13. </div>
  14. </div>
  15. {% endblock %}

Password Reset Done

accounts/tests/test_view_password_reset.py

  1. from django.contrib.auth import views as auth_views
  2. from django.core.urlresolvers import reverse
  3. from django.urls import resolve
  4. from django.test import TestCase
  5. class PasswordResetDoneTests(TestCase):
  6. def setUp(self):
  7. url = reverse('password_reset_done')
  8. self.response = self.client.get(url)
  9. def test_status_code(self):
  10. self.assertEquals(self.response.status_code, 200)
  11. def test_view_function(self):
  12. view = resolve('/reset/done/')
  13. self.assertEquals(view.func.view_class, auth_views.PasswordResetDoneView)

密码重置确认视图

templates/password_reset_confirm.html

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}
  3. {% if validlink %}
  4. Change password for {{ form.user.username }}
  5. {% else %}
  6. Reset your password
  7. {% endif %}
  8. {% endblock %}
  9. {% block content %}
  10. <div class="row justify-content-center">
  11. <div class="col-lg-6 col-md-8 col-sm-10">
  12. <div class="card">
  13. <div class="card-body">
  14. {% if validlink %}
  15. <h3 class="card-title">Change password for @{{ form.user.username }}</h3>
  16. <form method="post" novalidate>
  17. {% csrf_token %}
  18. {% include 'includes/form.html' %}
  19. <button type="submit" class="btn btn-success btn-block">Change password</button>
  20. </form>
  21. {% else %}
  22. <h3 class="card-title">Reset your password</h3>
  23. <div class="alert alert-danger" role="alert">
  24. It looks like you clicked on an invalid password reset link. Please try again.
  25. </div>
  26. <a href="{% url 'password_reset' %}" class="btn btn-secondary btn-block">Request a new password reset link</a>
  27. {% endif %}
  28. </div>
  29. </div>
  30. </div>
  31. </div>
  32. {% endblock %}

这个页面只能通过电子邮件访问,它看起来像这样:http://127.0.0.1:8000/reset/Mw/4po-2b5f2d47c19966e294a1/

在开发阶段,从控制台中的电子邮件获取此链接。

如果链接是有效的:

Password Reset Confirm Valid

倘若链接已经被使用:

Password Reset Confirm Invalid

accounts/tests/test_view_password_reset.py

  1. from django.contrib.auth.tokens import default_token_generator
  2. from django.utils.encoding import force_bytes
  3. from django.utils.http import urlsafe_base64_encode
  4. from django.contrib.auth import views as auth_views
  5. from django.contrib.auth.forms import SetPasswordForm
  6. from django.contrib.auth.models import User
  7. from django.core.urlresolvers import reverse
  8. from django.urls import resolve
  9. from django.test import TestCase
  10. class PasswordResetConfirmTests(TestCase):
  11. def setUp(self):
  12. user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
  13. '''
  14. create a valid password reset token
  15. based on how django creates the token internally:
  16. https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280
  17. '''
  18. self.uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
  19. self.token = default_token_generator.make_token(user)
  20. url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token})
  21. self.response = self.client.get(url, follow=True)
  22. def test_status_code(self):
  23. self.assertEquals(self.response.status_code, 200)
  24. def test_view_function(self):
  25. view = resolve('/reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token))
  26. self.assertEquals(view.func.view_class, auth_views.PasswordResetConfirmView)
  27. def test_csrf(self):
  28. self.assertContains(self.response, 'csrfmiddlewaretoken')
  29. def test_contains_form(self):
  30. form = self.response.context.get('form')
  31. self.assertIsInstance(form, SetPasswordForm)
  32. def test_form_inputs(self):
  33. '''
  34. The view must contain two inputs: csrf and two password fields
  35. '''
  36. self.assertContains(self.response, '<input', 3)
  37. self.assertContains(self.response, 'type="password"', 2)
  38. class InvalidPasswordResetConfirmTests(TestCase):
  39. def setUp(self):
  40. user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
  41. uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
  42. token = default_token_generator.make_token(user)
  43. '''
  44. invalidate the token by changing the password
  45. '''
  46. user.set_password('abcdef123')
  47. user.save()
  48. url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
  49. self.response = self.client.get(url)
  50. def test_status_code(self):
  51. self.assertEquals(self.response.status_code, 200)
  52. def test_html(self):
  53. password_reset_url = reverse('password_reset')
  54. self.assertContains(self.response, 'invalid password reset link')
  55. self.assertContains(self.response, 'href="{0}"'.format(password_reset_url))

密码重置完成视图

templates/password_reset_complete.html

  1. {% extends 'base_accounts.html' %}
  2. {% block title %}Password changed!{% endblock %}
  3. {% block content %}
  4. <div class="row justify-content-center">
  5. <div class="col-lg-6 col-md-8 col-sm-10">
  6. <div class="card">
  7. <div class="card-body">
  8. <h3 class="card-title">Password changed!</h3>
  9. <div class="alert alert-success" role="alert">
  10. You have successfully changed your password! You may now proceed to log in.
  11. </div>
  12. <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
  13. </div>
  14. </div>
  15. </div>
  16. </div>
  17. {% endblock %}

Password Reset Complete

accounts/tests/test_view_password_reset.py (view complete file contents)

  1. from django.contrib.auth import views as auth_views
  2. from django.core.urlresolvers import reverse
  3. from django.urls import resolve
  4. from django.test import TestCase
  5. class PasswordResetCompleteTests(TestCase):
  6. def setUp(self):
  7. url = reverse('password_reset_complete')
  8. self.response = self.client.get(url)
  9. def test_status_code(self):
  10. self.assertEquals(self.response.status_code, 200)
  11. def test_view_function(self):
  12. view = resolve('/reset/complete/')
  13. self.assertEquals(view.func.view_class, auth_views.PasswordResetCompleteView)

密码更改视图

此视图旨在提供给希望更改其密码的登录用户使用。通常,这些表单由三个字段组成:旧密码、新密码、新密码确认。

myproject/urls.py (view complete file contents)

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

这些视图仅适合登录用户,他们使用名为 @login_required的装饰器,此装饰器可防止非授权用户访问此页面。如果用户没有登录,Django会将他们重定向到登录页面。

现在我们必须在settings.py中定义我们应用程序的登录URL:

myproject/settings.py (view complete file contents)

  1. LOGIN_URL = 'login'

templates/password_change.html

  1. {% extends 'base.html' %}
  2. {% block title %}Change password{% endblock %}
  3. {% block breadcrumb %}
  4. <li class="breadcrumb-item active">Change password</li>
  5. {% endblock %}
  6. {% block content %}
  7. <div class="row">
  8. <div class="col-lg-6 col-md-8 col-sm-10">
  9. <form method="post" novalidate>
  10. {% csrf_token %}
  11. {% include 'includes/form.html' %}
  12. <button type="submit" class="btn btn-success">Change password</button>
  13. </form>
  14. </div>
  15. </div>
  16. {% endblock %}

Change Password

templates/password_change_done.html

  1. {% extends 'base.html' %}
  2. {% block title %}Change password successful{% endblock %}
  3. {% block breadcrumb %}
  4. <li class="breadcrumb-item"><a href="{% url 'password_change' %}">Change password</a></li>
  5. <li class="breadcrumb-item active">Success</li>
  6. {% endblock %}
  7. {% block content %}
  8. <div class="alert alert-success" role="alert">
  9. <strong>Success!</strong> Your password has been changed!
  10. </div>
  11. <a href="{% url 'home' %}" class="btn btn-secondary">Return to home page</a>
  12. {% endblock %}

Change Password Successful

关于密码更改视图,我们可以执行类似的测试用例,就像我们迄今为止所做的那样。创建一个名为test_view_password_change.py的新测试文件。

我将在下面列出新的测试类型。你可以检查我为密码更改视图编写的所有测试,然后单击代码段旁边的查看文正文件内容链接。大部分测试与我们迄今为止所做的相似。我转移到一个外部文件以避免太过于复杂。

accounts/tests/test_view_password_change.py (view complete file contents)

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

上面的测试尝试访问password_change视图而不登录。预期的行为是将用户重定向到登录页面。

accounts/tests/test_view_password_change.py (view complete file contents)

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

在这里我们定义了一个名为PasswordChangeTestCase 的新类。它将进行基本的设置,创建用户并向 password_change视图发送一个POST 请求。在下一组测试用例中,我们将使用这个类而不是 TestCase类来测试成功请求和无效请求:

accounts/tests/test_view_password_change.py (view complete file contents)

  1. class SuccessfulPasswordChangeTests(PasswordChangeTestCase):
  2. def setUp(self):
  3. super().setUp({
  4. 'old_password': 'old_password',
  5. 'new_password1': 'new_password',
  6. 'new_password2': 'new_password',
  7. })
  8. def test_redirection(self):
  9. '''
  10. A valid form submission should redirect the user
  11. '''
  12. self.assertRedirects(self.response, reverse('password_change_done'))
  13. def test_password_changed(self):
  14. '''
  15. refresh the user instance from database to get the new password
  16. hash updated by the change password view.
  17. '''
  18. self.user.refresh_from_db()
  19. self.assertTrue(self.user.check_password('new_password'))
  20. def test_user_authentication(self):
  21. '''
  22. Create a new request to an arbitrary page.
  23. The resulting response should now have an `user` to its context, after a successful sign up.
  24. '''
  25. response = self.client.get(reverse('home'))
  26. user = response.context.get('user')
  27. self.assertTrue(user.is_authenticated)
  28. class InvalidPasswordChangeTests(PasswordChangeTestCase):
  29. def test_status_code(self):
  30. '''
  31. An invalid form submission should return to the same page
  32. '''
  33. self.assertEquals(self.response.status_code, 200)
  34. def test_form_errors(self):
  35. form = self.response.context.get('form')
  36. self.assertTrue(form.errors)
  37. def test_didnt_change_password(self):
  38. '''
  39. refresh the user instance from the database to make
  40. sure we have the latest data.
  41. '''
  42. self.user.refresh_from_db()
  43. self.assertTrue(self.user.check_password('old_password'))

refresh_from_db()方法确保我们拥有最新的数据状态。它强制Django再次查询数据库以更新数据。考虑到change_password视图会更新数据库中的密码,我们必须这样做。为了查看测试密码是否真的改变了,我们必须从数据库中获取最新的数据。


总结

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

我们仍然需要改进很多关于代码设计的问题:模板文件夹开始变得乱七八糟。 boards 应用测试仍然是混乱的。此外,我们必须开始重构新的主题视图,因为现在我们可以检索登录的用户。我们很快就将做到这一点。

我希望你喜欢本教程系列的第四部分!第五部分将于2017年10月2日下周发布,如果您希望在第五部分结束的时候收到通过,请您订阅我们的邮件列表。

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

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

{% endraw %}