這一章節將會全面介紹 Django 的身份認證系統,咱們將實現註冊、登陸、註銷、密碼重置和密碼修改的整套流程。css
同時你還會了解到如何保護某些試圖以防未受權的用戶訪問,以及如何訪問已登陸用戶的我的信息。html
在接下來的部分,你會看到一些和身份驗證有關線框圖,將在本教程中實現。以後是一個全新Django 應用的初始化設置。至今爲止咱們一直在一個名叫 boards 的應用中開發。不過,全部身份認證相關的內容都將在另外一個應用中,這樣能更良好的組織代碼。 python
咱們必須更新一下應用的線框圖。首先,咱們須要在頂部菜單添加一些新選項,若是用戶未經過身份驗證,應該有兩個按鈕:分別是註冊和登陸按鈕。jquery
若是用戶已經經過身份認證,咱們應該顯示他們的名字,和帶有「個人帳戶」,「修改密碼」,「登出」這三個選項的下拉框git
在登陸頁面,咱們須要一個帶有username和password的表單, 一個登陸的按鈕和可跳轉到註冊頁面和密碼重置頁面的連接。github
在註冊頁面,咱們應該有包含四個字段的表單:username,email address, password和password confirmation。同時,也應該有一個可以訪問登陸頁面連接。ajax
在密碼重置頁面上,只有email address字段的表單。正則表達式
以後,用戶在點擊帶有特殊token的重置密碼連接之後,用戶將被重定向到一個頁面,在那裏他們能夠設置新的密碼。sql
要管理這些功能,咱們能夠在另外一個應用(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 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': 'john@doe.com', '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
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), ]
咱們從Django的contrib模塊導入了views ,咱們將其改名爲auth_views 以免與boards.views發生衝突。注意這個視圖有點不一樣: LogoutView.as_view()
。這是一個Django的「基於類」的視圖,到目前爲止,咱們只將類實現爲python函數。基於類的視圖提供了一種更加靈活的方式來擴展和重用視圖。稍後咱們將討論更多這個主題。
打開settings.py文件,而後添加LOGOUT_REDIRECT_URL
變量到文件的底部:
myproject/settings.py
LOGOUT_REDIRECT_URL = 'home'
在這裏咱們給變量指定了一個URL模型的名稱,以告訴Django當用戶退出登陸以後跳轉的地址。
在這以後,此次重定向就算完成了。只須要訪問URL 127.0.0.1:8000/logout/ 而後您就將被註銷。可是再等一下,在你註銷以前,讓咱們爲登陸用戶建立下拉菜單。
如今咱們須要在 base.html模板中進行一些調整。咱們必須添加一個帶註銷連接的下拉菜單。
Bootstrap 4 下拉組件須要jQuery才能工做。
首先,咱們前往 jquery.com/download/,而後下載壓縮的 jQuery 3.2.1版本。
在靜態文件夾中,建立一個名爲js的新文件夾。將jquery-3.2.1.min.js文件複製到那裏。
Bootstrap4還須要一個名爲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.js文件複製到咱們的js文件夾中。最終的結果應該是:
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 %}後面添加腳本:
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>
若是你發現上面的說明很模糊,只須要直接在下面的連接下載文件
打開連接,右鍵另存爲
如今咱們能夠添加Bootstrap4下拉菜單了:
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>
如今,咱們告訴Django程序,要在用戶登陸時顯示下拉菜單,若是沒有,則顯示登陸並註冊按鈕:
首先,添加一個新的URL路徑:
myproject/urls.py
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), ]
在as_view()
中,咱們能夠傳遞一些額外的參數,以覆蓋默認值。在這種狀況下,咱們讓LoginView 使用login.html模板。
編輯settings.py而後添加
myproject/settings.py
LOGIN_REDIRECT_URL = 'home'
這個配置信息告訴Django在成功登陸後將用戶重定向到哪裏。
最後,將登陸URL添加到 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.html和login.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 %} <!-- code suppressed --> {% endfor %}
{% if forloop.last %}
只是一個小事情,由於p
標籤有一個空白的margin-bottom
.一個表單可能有幾個non-field error,咱們呈現了一個帶有錯誤的p
標籤。而後我要檢查它是不是最後一次渲染的錯誤。若是是這樣的,咱們就添加一個 Bootstrap 4 CSS類 mb-0
,它的做用是表明了「margin bottom = 0」(底部邊緣爲0)。這樣的話警告看起來就不那麼奇怪了而且多了一些額外的空間。這只是一個很是小的細節。我這麼作的緣由只是爲了保持間距的一致性。
儘管如此,咱們仍然須要處理密碼字段。問題在於,Django從不將密碼字段的數據返回給客戶端。所以,在某些狀況下,不要試圖作一次自做聰明的事情,咱們能夠直接忽略is-valid
和is-invalid
的CSS類。可是咱們的表單模板看起來十分的複雜,咱們能夠將一些代碼移動到模板標記中去。
在boards應用中,建立一個名爲templatetags的新文件夾。而後在該文件夾內建立兩個名爲 *init*.py 和 form_tags.py的空文件。
文件結構應該以下:
myproject/ |-- myproject/ | |-- accounts/ | |-- boards/ | | |-- migrations/ | | |-- templatetags/ <-- here | | | |-- __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_tweaks 或static 模板標籤同樣。請注意,在建立這個文件後,你將不得不手動中止開發服務器並重啓它,以便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的測試。就像咱們對account app 所作的那樣。建立一個新的文件夾名爲tests,添加一個init.py,複製test.py而且將其重命名爲test_views.py。
添加一個名爲 test_templatetags.py的新空文件。
myproject/ |-- myproject/ | |-- accounts/ | |-- boards/ | | |-- migrations/ | | |-- templatetags/ | | |-- tests/ | | | |-- __init__.py | | | |-- test_templatetags.py <-- new file, empty for now | | | +-- test_views.py <-- our old file with all the tests | | |-- __init__.py | | |-- admin.py | | |-- apps.py | | |-- models.py | | +-- views.py | |-- myproject/ | |-- static/ | |-- templates/ | |-- db.sqlite3 | +-- manage.py +-- venv/
修復test_views.py的導入問題:
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的調試工具檢查電子郵件是否正確發送。
這個主意來自於項目開發過程當中,而不是發送真實的電子郵件,咱們只須要記錄它們。咱們有兩種選擇:將全部電子郵件寫入文本文件或僅將其顯示在控制檯中。我發現第二個方式更加方便,由於咱們已經在使用控制檯來運行開發服務器,而且設置更容易一些。
編輯 settings.py模塊並將EMAIL_BACKEND
變量添加到文件的末尾。
myproject/settings.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
密碼重置過程須要四個視圖:
這些視圖是內置的,咱們不須要執行任何操做,咱們所須要作的就是將路徑添加到 urls.py而且建立模板。
myproject/urls.py (完整代碼)
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'), ]
在密碼重置視圖中,template_name
參數是可選的。但我認爲從新定義它是個好主意,所以視圖和模板之間的連接比僅使用默認值更加明顯。
在 templates文件夾中,新增以下模板文件
在咱們開始實現模板以前,讓咱們準備一個新的測試文件。
咱們能夠添加一些基本的測試,由於這些視圖和表單已經在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 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,