完整的Django入門指南學習筆記4
前言
這一章節將會全面介紹 Django 的身份認證系統,我們將實現註冊、登入、登出、密碼重置和密碼修改的整套流程。
同時你還會了解到如何保護某些試圖以防未授權的使用者訪問,以及如何訪問已登入使用者的個人資訊。
在接下來的部分,你會看到一些和身份驗證有關線框圖,將在本教程中實現。之後是一個全新Django 應用的初始化設定。至今為止我們一直在一個名叫 boards 的應用中開發。不過,所有身份認證相關的內容都將在另一個應用中,這樣能更良好的組織程式碼。
線框圖
我們必須更新一下應用的線框圖。首先,我們需要在頂部選單新增一些新選項,如果使用者未通過身份驗證,應該有兩個按鈕:分別是註冊和登入按鈕。
如果使用者已經通過身份認證,我們應該顯示他們的名字,和帶有“我的賬戶”,“修改密碼”,“登出”這三個選項的下拉框
在登入頁面,我們需要一個帶有username和password的表單, 一個登入的按鈕和可跳轉到註冊頁面和密碼重置頁面的連結。
在註冊頁面,我們應該有包含四個欄位的表單:username,email address, password和password confirmation。同時,也應該有一個能夠訪問登入頁面連結。
在密碼重置頁面上,只有email address欄位的表單。
之後,使用者在點選帶有特殊token的重置密碼連結以後,使用者將被重定向到一個頁面,在那裡他們可以設定新的密碼。
初始設定
要管理這些功能,我們可以在另一個應用(app)中將其拆解。在專案根目錄中的 manage.py 檔案所在的同一目錄下,執行以下命令以建立一個新的app:
django-admin startapp accounts
專案的目錄結構應該如下:
myproject/ |-- myproject/ | |-- accounts/ <-- 新建立的app | |-- boards/ | |-- myproject/ | |-- static/ | |-- templates/ | |-- db.sqlite3 | +-- manage.py +-- venv/
下一步,在 settings.py 檔案中將 accounts app 新增到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 這個app下操作。
註冊
我們從建立註冊檢視開始。首先,在urls.py
檔案中建立一個新的路由:
myproject/urls.py
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), ]
注意,我們以不同的方式從accounts
app 匯入了views
模組
from accounts import views as accounts_views
我們給 accounts 的 views
指定了別名,否則它會與boards
的views
模組發生衝突。稍後我們可以改進urls.py
的設計,但現在,我們只關注身份驗證功能。
現在,我們在 accounts app 中編輯 views.py,新建立一個名為signup的檢視函式:
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
from django.urls import resolve 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)以及 URL /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'...
對於認證檢視(註冊、登入、密碼重置等),我們不需要頂部條和breadcrumb導航欄,但仍然能夠複用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 %}
表示新增一些額外的CSS,用於某些特定的頁面。
程式碼塊{% block body %}
包裝了整個HTML文件。我們可以只有一個空的文件結構,以充分利用base.html頭部。注意,還有一個結束的程式碼塊{% endblock body %}
,在這種情況下,命名結束標籤是一種很好的實踐方法,這樣更容易確定結束標記的位置。
現在,在signup.html模板中,我們使用{% block body %}
代替了 {% block content %}
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出於安全考慮的特性。在預設的情況下,Django將所有字串視為不安全的,會轉義所有可能導致問題的特殊字元。但在這種情況下,我們可以信任它。
templates/includes/form.html
{% load widget_tweaks %}
{% for field in form %} <div class="form-group"> {{ field.label_tag }} <!-- code suppressed for brevity --> {% 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
,但後來我意識到Django1.11對登入檢視LoginView具有基於類的檢視,因此不存在與名稱衝突的風險。在比較舊的版本中,有一個auth.login
和auth.view.login
,這會導致一些混淆,因為一個是使用者登入的功能,另一個是檢視。簡單來說:如果你願意,你可以像
login
一樣匯入它,這樣做不會造成任何問題。)
如果表單是有效的,那麼我們通過user=form.save()
建立一個User例項。然後將建立的使用者作為引數傳遞給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 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)
我們稍微改變了SighUpTests類,定義了一個setUp方法,將response物件移到那裡,現在我們測試響應中是否有表單和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 from django.test import TestCase from .views import signup class SignUpTests(TestCase): # code suppressed... 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 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欄位新增到表單
一切都正常,但還缺失 email address欄位。UserCreationForm不提供 email 欄位,但是我們可以對它進行擴充套件。
在accounts 資料夾中建立一個名為forms.py的檔案:
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})
只用這個小小的改變,可以運作了:
請記住更改測試用例以使用SignUpForm而不是UserCreationForm:
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': '[email protected]', 'password1': 'abcdef123456', 'password2': 'abcdef123456' } self.response = self.client.post(url, data) self.home_url = reverse('home') # ...
之前的測試用例仍然會通過,因為SignUpForm擴充套件了UserCreationForm,它是UserCreationForm的一個例項。
添加了新的表單後,讓我們想想發生了什麼:
fields = ('username', 'email', 'password1', 'password2')
它會自動對映到HTML模板中。這很好嗎?這要視情況而定。如果將來會有新的開發人員想要重新使用SignUpForm來做其他事情,併為其新增一些額外的欄位。那麼這些新的欄位也會出現在signup.html中,這可能不是所期望的行為。這種改變可能會被忽略,我們不希望有任何意外。
那麼讓我們來建立一個新的測試,驗證模板中的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的新資料夾。然後在tests資料夾中,建立一個名為init.py
的空檔案。
現在,將test.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中修復匯入:
accounts/tests/test_view_signup.py
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.urls import resolve from django.test import TestCase from ..views import signup from ..forms import SignUpForm
我們在應用程式模組內部使用相對匯入,以便我們可以自由地重新命名Django應用程式,而無需修復所有絕對匯入。
現在讓我們建立一個新的測試檔案來測試SignUpForm,新增一個名為test_form_signup.py的新測試檔案:
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,以包含使用者的名字和姓氏,那麼即使我們沒有破壞任何東西,我們也可能最終不得不修復一些測試用例。
這些警報很有用,因為它們有助於提高認識,特別是新手第一次接觸程式碼,它可以幫助他們自信地編碼。
改進註冊模板
讓我們稍微討論一下,在這裡,我們可以使用Bootstrap4 元件來使它看起來不錯。
訪問:https://www.toptal.com/designers/subtlepatterns/ 並找到一個很好地背景圖案作為賬戶頁面的背景,下載下來再靜態資料夾中建立一個名為img的新資料夾,並將影象放置再那裡。
之後,再static/css中建立一個名為accounts.css的新CSS檔案。結果應該如下:
myproject/ |-- myproject/ | |-- accounts/ | |-- boards/ | |-- myproject/ | |-- static/ | | |-- css/ | | | |-- accounts.css <-- here | | | |-- app.css | | | +-- bootstrap.min.css | | +-- img/ | | | +-- shattered.png <-- here (the name may be different, depending on the patter you downloaded) | |-- 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,並使用Bootstrap4元件:
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以新增新的路由:
myproject/urls.py
f