Django初學(xué)者入門指南4-登錄認(rèn)證(譯&改)

Django初學(xué)者入門指南1-初識(shí)(譯&改)

Django初學(xué)者入門指南2-基礎(chǔ)知識(shí)(譯&改)

Django初學(xué)者入門指南3-高級(jí)概念(譯&改)

Django初學(xué)者入門指南4-登錄認(rèn)證(譯&改)

Django初學(xué)者入門指南5-存儲(chǔ)數(shù)據(jù)(譯&改)

Django初學(xué)者入門指南6-基于類的頁面(譯&改)

Django初學(xué)者入門指南7-部署發(fā)布(譯&改)

>>原文地址 By Vitor Freitas

簡(jiǎn)介

本教程將介紹Django的身份驗(yàn)證系統(tǒng)。我們將實(shí)現(xiàn)整個(gè)過程:注冊(cè)聚凹、登錄、退出登錄、密碼重置和密碼更改。

還將簡(jiǎn)要介紹如何防止未經(jīng)授權(quán)的用戶訪問某些頁面盈滴,以及已登錄用戶如何訪問這些被保護(hù)的信息爸舒。

在本次教程中屯曹,我們將根據(jù)線框圖實(shí)現(xiàn)與身份驗(yàn)證相關(guān)的頁面谆刨,同時(shí)會(huì)創(chuàng)建一個(gè)新的Django應(yīng)用程序并對(duì)它進(jìn)行初始化設(shè)置导狡。到目前為止鉴逞,我們一直在開發(fā)的是名為boards的應(yīng)用程序记某。而所有與身份驗(yàn)證相關(guān)的東西都可以放在不同的應(yīng)用程序中司训,這樣的架構(gòu)設(shè)計(jì)更加合理,有利于后期維護(hù)液南。

賬號(hào)系統(tǒng)應(yīng)用程序

線框圖

我們需要更新應(yīng)用程序的線框圖:首先我們需要在頂部菜單添加更多的新選項(xiàng)壳猜,當(dāng)用戶沒有登錄時(shí),顯示注冊(cè)和登錄兩個(gè)按鈕滑凉。

圖1:未登錄用戶的頂部條樣式

如果用戶已經(jīng)登錄了统扳,那么就需要在這個(gè)位置顯示用戶名,同時(shí)提供三個(gè)選項(xiàng)的一個(gè)下拉菜單:我的賬號(hào)畅姊、修改密碼咒钟、退出登錄。

圖2:已登錄用戶的頂部條樣式

我們?cè)賮碓O(shè)計(jì)登錄頁面若未,這里需要一個(gè)表單Form朱嘴,包含usernamepassword字段,一個(gè)主要功能按鈕(登錄)以及兩個(gè)跳轉(zhuǎn)其他頁面的按鈕:注冊(cè)頁面和重置密碼粗合。

圖3:登錄頁面

在注冊(cè)頁面我們需要一個(gè)包含username萍嬉、email addresspassword隙疚、password confirmation四個(gè)字段的表單Form壤追,同樣也需要提供可以回到登錄頁面的按鈕。

圖4:注冊(cè)頁面

在密碼重置頁面供屉,我們只需要一個(gè)包含email address字段的表單行冰。

圖5:密碼重置頁面

當(dāng)用戶發(fā)起重置密碼成功后,會(huì)通過郵件中的帶特殊token鏈接跳轉(zhuǎn)到下面這個(gè)設(shè)置密碼的頁面:

圖6:設(shè)置密碼

創(chuàng)建并初始化賬號(hào)系統(tǒng)應(yīng)用程序

為了方便管理這些信息和功能伶丐,我們來創(chuàng)建一個(gè)新的應(yīng)用程序资柔。回到項(xiàng)目的根目錄撵割,也就是manage.py文件所在的目錄贿堰,運(yùn)行下面的命令:

django-admin startapp accounts

現(xiàn)在的項(xiàng)目目錄結(jié)構(gòu)應(yīng)該如下所示:

myproject/
 |-- myproject/
 |    |-- accounts/     <-- 新的賬號(hào)系統(tǒng)應(yīng)用程序
 |    |-- boards/
 |    |-- myproject/
 |    |-- static/
 |    |-- templates/
 |    |-- db.sqlite3
 |    +-- manage.py
 +-- venv/

將新的accounts應(yīng)用程序配置到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',
]

接下來,讓我們?cè)?strong>accounts這個(gè)新的應(yīng)用程序里開發(fā)吧:


注冊(cè)

讓我們先來創(chuàng)建注冊(cè)頁面啡彬,第一步羹与,將新的路由添加到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>

需要注意的是這次我們引入應(yīng)用程序accountsviews時(shí),使用了不同的寫法:

from accounts import views as accounts_views

我們給它起了一個(gè)別名accounts_views庶灿,否則它會(huì)跟boardsviews命名沖突纵搁。后面我們?cè)賮韮?yōu)化urls.py,現(xiàn)在讓我們先集中精力做好賬號(hào)系統(tǒng)往踢。

現(xiàn)在我們把signup頁面方法添加到下面的代碼到accounts應(yīng)用目錄下的views.py文件里:

accounts/views.py

from django.shortcuts import render

def signup(request):
    return render(request, 'signup.html')

創(chuàng)建注冊(cè)的頁面模板signup.html

templates/signup.html

{% extends 'base.html' %}

{% block content %}
  <h2>Sign up</h2>
{% endblock %}

在瀏覽器中訪問http://127.0.0.1:8000/signup/看是否能正常運(yùn)行:

注冊(cè)頁面

再添加一點(diǎn)測(cè)試代碼:

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)

測(cè)試請(qǐng)求狀態(tài)碼是否為成功(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'...

在新的用戶認(rèn)證相關(guān)頁面(注冊(cè)、登錄利职、重置密碼等)趣效,不會(huì)用到我們之前添加的頂部導(dǎo)航條,但仍需要使用base.html這個(gè)母模板猪贪。讓我們稍微改一改它:

templates/base.html

{% load static %}<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>{% block title %}Django Boards{% endblock %}</title>
    <link  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 %}這個(gè)塊包含也整個(gè)頁面的body部分热押,我們可以利用base.html來創(chuàng)建一個(gè)空文檔西傀。需要注意的是,在塊結(jié)束部分我們使用了{% endblock body %}桶癣。在比較復(fù)雜的場(chǎng)景下拥褂,建議也給結(jié)束標(biāo)記添加名稱,這樣可以很直觀的看清整個(gè)文檔牙寞。

回到注冊(cè)頁面signup.html饺鹃,現(xiàn)在我們更換{% block content %}{% block body %}

templates/signup.html

{% extends 'base.html' %}

{% block body %}
  <h2>Sign up</h2>
{% endblock %}
注冊(cè)頁面

現(xiàn)在來創(chuàng)建注冊(cè)請(qǐng)求表單吧碎税,Django有一個(gè)內(nèi)置的類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 %}
注冊(cè)頁面

看起來頁面元素有點(diǎn)亂馏锡,讓我們優(yōu)化一下form.html來解決這個(gè)問題:

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 %}
注冊(cè)頁面

看起來好多了雷蹂,form.html這個(gè)文件某些地方現(xiàn)在還在顯示原始的HTML字符串。這是一個(gè)安全的功能杯道,Django默認(rèn)會(huì)把所以字符串看作不安全的匪煌,并把所有可能導(dǎo)致異常問題的特殊符號(hào)排除在外。不過現(xiàn)在党巾,我們可以先禁用這個(gè)功能萎庭。

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 }}  <!-- 更新了這個(gè) -->
      </small>
    {% endif %}
  </div>
{% endfor %}

這里就是把safe添加給了field.help_text得到:{{ field.help_text|safe }}齿拂。

保存form.html文件驳规,讓我們重新打開注冊(cè)頁面看一看:

注冊(cè)頁面

現(xiàn)在我們把業(yè)務(wù)邏輯添加到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})

這里有個(gè)小細(xì)節(jié):引入的登錄login方法被重命名為了auth_login,這是為了防止跟login這個(gè)內(nèi)置登錄頁面出現(xiàn)沖突署海。

提示: 這里我將login重命名為了auth_login吗购,后來我注意到Django 1.11已經(jīng)實(shí)現(xiàn)了基于類實(shí)現(xiàn)的頁面LoginView所以這里其實(shí)沒有命名沖突的風(fēng)險(xiǎn)。

在更早的Django版本里砸狞,存在這樣的內(nèi)置方法auth.loginauth.view.login捻勉,這兩個(gè)login一個(gè)是頁面而另一個(gè)是方法,比較容易出現(xiàn)沖突刀森。

長(zhǎng)話短說踱启,你可以直接使用login,它不會(huì)導(dǎo)致任何問題。

User實(shí)例會(huì)在用戶提交的數(shù)據(jù)被驗(yàn)證通過后直接調(diào)用user = form.save()創(chuàng)建并保存埠偿,然后被創(chuàng)建的用戶實(shí)例會(huì)被作為參數(shù)傳遞給auth_login方法來主動(dòng)驗(yàn)證用戶信息透罢,最后頁面會(huì)重新回到首頁,保證正常的使用流程胚想。

讓我們?cè)囈辉囁銎荆忍峤灰恍┎缓戏ǖ臄?shù)據(jù),如空的表單浊服、不符合規(guī)則的文字统屈、或者已經(jīng)存在的用戶名:

各種異常

現(xiàn)在讓我們輸入正確的信息,看是否能注冊(cè)成功并正確跳轉(zhuǎn)到首頁:

回到首頁
已登錄用戶的頁面顯示

我們?cè)趺粗烙脩粢呀?jīng)登錄呢牙躺?讓我們先在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 %}
注冊(cè)頁面測(cè)試

讓我們更新一下測(cè)試用例:

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方法,將請(qǐng)求返回放到了這里∧蹩剑現(xiàn)在我們也來測(cè)試一下是否包含表單數(shù)據(jù)和CSRF token吨掌。

讓我們創(chuàng)建一個(gè)新的測(cè)試類來測(cè)試成功的注冊(cè)行為:

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)

運(yùn)行單元測(cè)試吧脓恕。

使用同樣的方式膜宋,我們?cè)賱?chuàng)建一個(gè)新測(cè)試類來測(cè)試非法注冊(cè)請(qǐng)求:

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內(nèi)置的類UserCreationForm不包含email字段秋茫。所以我們要擴(kuò)展它。
創(chuàng)建一個(gè)新的forms.py文件乃秀,并放到accounts應(yīng)用目錄下:

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')

現(xiàn)在我們將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})

這樣簡(jiǎn)單改動(dòng)一下肛著,就可以了:

這里記得把測(cè)試用例里的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')

    # ...

之前寫的測(cè)試用例直接運(yùn)行也能通過,因?yàn)?strong>SignUpForm是UserCreationForm的子類跺讯,生成的實(shí)例也包含UserCreationForm的所有屬性枢贿。

我們新增了一個(gè)表單域:

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

HTML模板會(huì)自動(dòng)根據(jù)這個(gè)進(jìn)行頁面渲染,方便又快捷刀脏。但是如果未來SignUpForm被繼續(xù)擴(kuò)展新增其他字段局荚,這些新的字段也會(huì)顯示到這個(gè)注冊(cè)頁面上,這并不是我們想看到的情況愈污。

所以讓我們添加一個(gè)新的單元測(cè)試耀态,驗(yàn)證這個(gè)模板中的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)
測(cè)試代碼的管理

好了,我們需要測(cè)試輸入以及其他可能的所有功能钙畔,還需要校驗(yàn)表單數(shù)據(jù)茫陆。這種情況下繼續(xù)把所有的測(cè)試代碼都寫到accounts/tests.py這個(gè)文件里就會(huì)顯得很臃腫,讓我們改進(jìn)測(cè)試代碼的管理吧擎析。

accounts應(yīng)用目錄下創(chuàng)建文件夾tests簿盅,然后在文件夾里創(chuàng)建一個(gè)空白文檔命名為__init__.py挥下。

再把tests.py文件移動(dòng)到新建的tests文件夾里,并將文件改名為test_view_signup.py桨醋。

最終的項(xiàng)目文件結(jié)構(gòu)如下所示:

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/

注意現(xiàn)在因?yàn)檎{(diào)整了文檔目錄結(jié)構(gòu)棚瘟,所以我們需要修改測(cè)試文件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

這里我們使用相對(duì)路徑進(jìn)行引入,這樣就避免了因?yàn)轫?xiàng)目改變位置而導(dǎo)致錯(cuò)誤的情況喜最。

首先創(chuàng)建一個(gè)新的文件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)

這里看起來數(shù)據(jù)校驗(yàn)非常嚴(yán)格偎蘸,比如未來我們?yōu)?strong>SignUpForm類新增了姓和名等屬性時(shí),也同樣需要到這里來修改測(cè)試用例瞬内。

測(cè)試用例

這種嚴(yán)格校驗(yàn)的測(cè)試用例在實(shí)際生產(chǎn)中非常有用迷雪,有助于新來的人了解項(xiàng)目代碼。

改進(jìn)注冊(cè)頁面模板

讓我們給注冊(cè)頁面模板添加一個(gè)Bootstrap 4卡片式背景板虫蝶。

打開https://www.toptal.com/designers/subtlepatterns/選一個(gè)你喜歡的背景圖片下載下來章咧,在項(xiàng)目static文件夾下創(chuàng)建一個(gè)文件夾img,把圖片都存儲(chǔ)到這里能真。

然后在static/css目錄下創(chuàng)建一個(gè)新文件accounts.css赁严,現(xiàn)在我們的目錄結(jié)果應(yīng)該是這樣的:

myproject/
 |-- myproject/
 |    |-- accounts/
 |    |-- boards/
 |    |-- myproject/
 |    |-- static/
 |    |    |-- css/
 |    |    |    |-- accounts.css  <-- 這里
 |    |    |    |-- app.css
 |    |    |    +-- bootstrap.min.css
 |    |    +-- img/
 |    |    |    +-- shattered.png  <-- 文件名可以隨意,這里使用的是下載默認(rèn)名稱
 |    |-- 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文件疼约,來應(yīng)用這個(gè)背景板:

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文件,新增一個(gè)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發(fā)生沖突蝙泼。還有你可能注意到了LogoutView.as_view()程剥,它是DJango的基于類實(shí)現(xiàn)的視圖。目前為止我們使用的都是使用Python方法生成的頁面踱承,而基于類實(shí)現(xiàn)的視圖可以更加靈活的擴(kuò)展和重用倡缠,后面我們會(huì)滲入討論這個(gè)問題哨免。

打開settings.py文件茎活,將LOGOUT_REDIRECT_URL添加到文檔底部:

myproject/settings.py

LOGOUT_REDIRECT_URL = 'home'

我們將退出登錄以后重定位跳轉(zhuǎn)的頁面定義為home

好了琢唾,這就可以了载荔,只要通過訪問http://127.0.0.1:8000/logout/就能實(shí)現(xiàn)退出登錄的功能。在退出登錄之前采桃,我們先為已登錄的用戶實(shí)現(xiàn)一個(gè)下拉菜單懒熙。


已登錄用戶的下拉菜單

現(xiàn)在我們又需要對(duì)base.html母模板進(jìn)行修改,增加一個(gè)可以退出登錄的下拉菜單普办。

Bootstrap 4的下拉菜單組件需要依賴jQuery工扎。

打開jquery.com/download/下載compressed, production jQuery 3.2.1這個(gè)版本.

下載jQuery

打開項(xiàng)目的static文件夾,并且創(chuàng)建一個(gè)名為js的文件夾衔蹲,將下載下來的文件里的jquery-3.2.1.min.js添加到這個(gè)新建文件夾下肢娘。

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文件夾里珊皿。

所以最終的結(jié)果是:

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

如果你感覺前面的說明不夠清楚,那么你可以用下面的鏈接直接下載:

如果沒有辦法下載上面的文件蟋定,你也可以從作者項(xiàng)目代碼里直接獲取得到途凫。

這里可以直接點(diǎn)擊右鍵,選擇保存溢吻。

現(xiàn)在讓我們添加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>
下拉菜單

讓我們?cè)囈辉囄眩c(diǎn)擊退出登錄:

退出登錄

成功了,但是下拉菜單在退出登錄后依然顯示著促王,并且用戶名顯示為空犀盟,讓我們稍微改進(jìn)一下:

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

現(xiàn)在這里加了一個(gè)判斷,當(dāng)用戶未登錄時(shí)蝇狼,顯示登錄和注冊(cè)按鈕:


登錄頁面

要想富阅畴,先修路:

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()傳擴(kuò)展參數(shù)去覆蓋默認(rèn)值,這個(gè)代碼里LoginView直接查找template_namelogin.html的頁面.

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

myproject/settings.py

LOGIN_REDIRECT_URL = 'home'

這個(gè)配置會(huì)告訴Django在用戶登錄成功后跳轉(zhuǎn)到哪個(gè)頁面迅耘。

我們還需要把登錄的地址寫到base.html模板里:

templates/base.html

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

我們創(chuàng)建一個(gè)login.html贱枣,并且使用注冊(cè)頁面的樣式:

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

讓我們重構(gòu)一下HTML,避免重復(fù)颤专。

創(chuàng)建一個(gè)賬戶專用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 %}

把它應(yīng)用到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 %}

現(xiàn)在我們還沒有實(shí)現(xiàn)重置密碼的頁面纽哥,這里我們先寫#

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

注意我們?cè)黾恿颂D(zhuǎn)到登錄頁面的標(biāo)簽<a href="{% url 'login' %}">Log in</a>栖秕。

表單的非字段格式校驗(yàn)錯(cuò)誤

如果我們沒有填寫表單信息春塌,我們會(huì)得到下面的錯(cuò)誤提示:

不過如果我們提交不存在的用戶名,或者無效的密碼簇捍,則會(huì)變成這樣:

這里會(huì)有點(diǎn)誤導(dǎo)用戶只壳,框邊是綠色,并且無任何提示信息暑塑。

這是因?yàn)楸韱芜€有另外一種特殊的錯(cuò)誤吼句,非字段格式校驗(yàn)錯(cuò)誤non-field errors,這種錯(cuò)誤與字段的格式無關(guān)事格。讓我們重構(gòu)form.html模板來顯示這種錯(cuò)誤:

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 %}這里使用了一個(gè)小技巧况毅。p標(biāo)簽有margin-bottom這個(gè)屬性,而一個(gè)表單可能有多個(gè)非字段格式校驗(yàn)錯(cuò)誤尔艇,這里我們就僅僅將該表單的最后一個(gè)錯(cuò)誤顯示出來尔许,免得過多的錯(cuò)誤提示,會(huì)擾亂頁面的布局终娃。這里Bootstrap 4 CSS類mb-0的意思就是margin bottom = 0味廊。

我們還需要考慮密碼字段,Django不會(huì)將密碼返回給前端棠耕。在某些條件下余佛,這里就先忽略is-validis-invalid這些CSS類。現(xiàn)在模板已經(jīng)開始變得復(fù)雜起來了窍荧,讓我們把一部分代碼移動(dòng)到template tag里吧辉巡。

創(chuàng)建自定義模板標(biāo)簽

boards應(yīng)用目錄下,創(chuàng)建一個(gè)新的文件夾templatetags蕊退,然后在這個(gè)文件夾下創(chuàng)建兩個(gè)新文件__init__.pyform_tags.py郊楣。

現(xiàn)在我們的項(xiàng)目結(jié)構(gòu)應(yīng)該是:

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/

然后我們?cè)?code>form_tags.py文件里創(chuàng)建兩個(gè)標(biāo)簽:

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模板標(biāo)記一樣。注意输硝,創(chuàng)建此文件后今瀑,必須手動(dòng)停止開發(fā)服務(wù)器并重新啟動(dòng)它,以便Django能夠識(shí)別并加載新的模板標(biāo)記点把。

{% load form_tags %}

這樣我們就可以使用它們了:

{{ form.username|field_type }}

這樣會(huì)得到:

'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'

現(xiàn)在我們到form.html里使用這些標(biāo)簽:

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

現(xiàn)在好多了吧橘荠,通過這種方式減少了大量冗余的代碼,并且它還修改了密碼框顯示為綠色的問題:

測(cè)試模板標(biāo)簽

現(xiàn)在讓我們重新梳理一下boards應(yīng)用程序下的測(cè)試吧郎逃,就像在accounts里做的一樣哥童,創(chuàng)建一個(gè)tests文件夾,添加一個(gè)__init__.py文件衣厘,并且拷貝tests.py到這個(gè)文件夾里如蚜,并重命名這個(gè)文件為test_views.py压恒。

創(chuàng)建一個(gè)新文件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/

調(diào)整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

試試執(zhí)行測(cè)試用例看是否正常型宙。

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']))

這里我們?yōu)闇y(cè)試類創(chuàng)建了表單實(shí)例來配合進(jìn)行測(cè)試。

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'...

重置密碼

重置密碼的流程需要匹配一些比較復(fù)雜的URL路由伦吠,這個(gè)我們?cè)谇耙粋€(gè)教程里已經(jīng)討論過了妆兑,這里不需要精通正則表達(dá)式魂拦,所以只需要知道常用的就好了。

另外一個(gè)重點(diǎn)是在重置密碼流程里搁嗓,需要我們發(fā)送一個(gè)包含重置鏈接的電子郵件芯勘。這個(gè)功能在初學(xué)時(shí)可能會(huì)比較困難,因?yàn)樗赡苄枰玫狡渌谌降姆?wù)腺逛。不過現(xiàn)在我們不需要部署產(chǎn)品級(jí)別的郵件服務(wù)荷愕,只需要使用Django的調(diào)試工具就可以檢測(cè)到這個(gè)郵件是否正常發(fā)送。

Email 控制臺(tái)后端

在項(xiàng)目的開發(fā)過程中棍矛,我們不發(fā)送真正的電子郵件安疗,而是記錄下來。這里有兩個(gè)選擇:在文本文件中寫入所有電子郵件够委,或只是在控制臺(tái)中顯示它們荐类。實(shí)際上后一個(gè)選項(xiàng)更方便,因?yàn)槲覀円呀?jīng)在使用一個(gè)控制臺(tái)來運(yùn)行開發(fā)服務(wù)器茁帽,而且設(shè)置也更容易一些玉罐。

編輯settings.py文件,添加EMAIL_BACKEND到文檔的底部:

myproject/settings.py

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

密碼重置流程需要4個(gè)頁面:

  • 一個(gè)表單頁面來開始進(jìn)行密碼重置潘拨;
  • 一個(gè)郵件發(fā)送成功頁面來提示用戶查看電子郵件厌小;
  • 一個(gè)頁面來驗(yàn)證用戶收到的驗(yàn)證碼是否有效;
  • 一個(gè)頁面來告訴用戶重置密碼成功或者失敗战秋。

這些頁面Django都有內(nèi)置模板璧亚,我們只需要配置urls.py并且創(chuàng)建模板就可以了。

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這個(gè)參數(shù)是可選參數(shù),不過我還是建議配置上课幕,這樣可以比默認(rèn)值更加清晰明了桂肌。

在模板目錄templates下,有以下這些模板:

  • password_reset.html
  • password_reset_email.html: 這是發(fā)送給用戶的郵件內(nèi)容
  • password_reset_subject.txt: 這是發(fā)送給用戶的郵件標(biāo)題疯搅,應(yīng)當(dāng)只有一行文字
  • password_reset_done.html
  • password_reset_confirm.html
  • password_reset_complete.html

在實(shí)現(xiàn)這些文件前,讓我們先準(zhǔn)備好測(cè)試代碼文件埋泵。

只需要一些簡(jiǎn)單的測(cè)試來測(cè)試我們自己應(yīng)用程序的功能幔欧,其他已經(jīng)在Django自己的代碼中做好了測(cè)試。

accounts/tests中創(chuàng)建一個(gè)新的測(cè)試文件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

讓我們?cè)?strong>accounts/tests里創(chuàng)建一個(gè)測(cè)試文件來專門測(cè)試郵件內(nèi)容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)

這個(gè)測(cè)試用例會(huì)抓取應(yīng)用程序發(fā)送的電子郵件礁蔗,檢查郵件的標(biāo)題、主要郵件內(nèi)容以及發(fā)送目標(biāo)是否正確雁社。

密碼重置成功頁面
  • 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)
密碼重置確認(rèn)頁面
  • 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 %}

這個(gè)頁面只能通過郵件里發(fā)送的鏈接訪問到浴井,類似這樣的鏈接http://127.0.0.1:8000/reset/Mw/4po-2b5f2d47c19966e294a1/

在開發(fā)階段,可以從控制臺(tái)獲取到這個(gè)鏈接霉撵。

如果這個(gè)鏈接是有效的磺浙,就可以訪問到下面的頁面:

或者這個(gè)鏈接已經(jīng)失效了:

  • 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)

修改密碼頁面

這個(gè)頁面是登錄后的用戶用來修改密碼的洪囤,一般來說,它的表單包含3個(gè)字段撕氧,舊密碼瘤缩、新密碼和新密碼確認(rèn)。

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款咖,這個(gè)裝飾器會(huì)阻止未授權(quán)用戶訪問,如果未授權(quán)用戶直接訪問奄喂,Django會(huì)重定位到登錄頁面铐殃。

讓我們?cè)侔训卿浡酚膳渲玫巾?xiàng)目設(shè)置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 %}

讓我們?yōu)樾薷拿艽a頁面添加測(cè)試用例,創(chuàng)建測(cè)試文件test_view_password_change.py跨新。

我將在下面列出新增的測(cè)試用例富腊,你可以單擊代碼段旁邊的完整原始文件下載鏈接,查看我為密碼更改視圖編寫的所有測(cè)試域帐。大多數(shù)測(cè)試與我們目前所做的相似赘被,我只是移到了外部以避免重復(fù)。

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}')

上面的測(cè)試用例嘗試在未登錄時(shí)訪問頁面password_change肖揣,預(yù)期的結(jié)果是會(huì)重定位到登錄頁面民假。

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)

這里我們定義了一個(gè)名為PasswordChangeTestCase的新測(cè)試類。它創(chuàng)建了一個(gè)用戶龙优,并向password_change頁面方法發(fā)出POST請(qǐng)求羊异。在下一組測(cè)試用例中,我們將使用這個(gè)類而不是TestCase類作為父類彤断,去測(cè)試成功的請(qǐng)求和無效的請(qǐng)求:

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()這個(gè)方法會(huì)獲取數(shù)據(jù)庫的最新數(shù)據(jù)野舶,它會(huì)讓Django強(qiáng)制查詢來更新數(shù)據(jù),這里因?yàn)橛脩粜薷牧嗣艽a保存到數(shù)據(jù)庫宰衙,所以必須要刷新一次數(shù)據(jù)才能準(zhǔn)確的測(cè)試用戶的密碼是否修改成功平道。


小結(jié)

身份驗(yàn)證對(duì)于大多數(shù)Django應(yīng)用程序來說是一個(gè)非常常見的用例。在本教程中供炼,我們實(shí)現(xiàn)了所有重要頁面:注冊(cè)一屋、登錄、注銷袋哼、密碼重置和更改密碼〖侥現(xiàn)在我們已經(jīng)有了一種方式來創(chuàng)建用戶并對(duì)它們進(jìn)行身份驗(yàn)證,這樣我們就能夠繼續(xù)開發(fā)應(yīng)用程序的其他頁面先嬉。

我們?nèi)匀恍枰倪M(jìn)代碼設(shè)計(jì)的許多地方:模板文件夾開始變得越來越混亂轧苫,文件太多。boards應(yīng)用程序測(cè)試仍然沒有梳理疫蔓。另外含懊,必須開始重構(gòu)新的主題頁面,因?yàn)楝F(xiàn)在我們可以判斷用戶是否登錄衅胀。

項(xiàng)目的源代碼可在GitHub上使用岔乔。項(xiàng)目的當(dāng)前狀態(tài)可在發(fā)布標(biāo)簽v0.4-lw下找到」銮可以通過下面的鏈接訪問:

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

上一節(jié):Django初學(xué)者入門指南3-高級(jí)概念(譯&改)

下一節(jié):Django初學(xué)者入門指南5-存儲(chǔ)數(shù)據(jù)(譯&改)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末雏门,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子掸掏,更是在濱河造成了極大的恐慌茁影,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件丧凤,死亡現(xiàn)場(chǎng)離奇詭異募闲,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)愿待,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門浩螺,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人仍侥,你說我怎么就攤上這事要出。” “怎么了农渊?”我有些...
    開封第一講書人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵患蹂,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我砸紊,道長(zhǎng)况脆,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任批糟,我火速辦了婚禮格了,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘徽鼎。我一直安慰自己盛末,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開白布否淤。 她就那樣靜靜地躺著悄但,像睡著了一般。 火紅的嫁衣襯著肌膚如雪石抡。 梳的紋絲不亂的頭發(fā)上檐嚣,一...
    開封第一講書人閱讀 49,144評(píng)論 1 285
  • 那天,我揣著相機(jī)與錄音啰扛,去河邊找鬼嚎京。 笑死嗡贺,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的鞍帝。 我是一名探鬼主播诫睬,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼帕涌!你這毒婦竟也來了摄凡?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤蚓曼,失蹤者是張志新(化名)和其女友劉穎亲澡,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體纫版,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡床绪,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了捎琐。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片会涎。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖瑞凑,靈堂內(nèi)的尸體忽然破棺而出末秃,到底是詐尸還是另有隱情,我是刑警寧澤籽御,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布练慕,位于F島的核電站,受9級(jí)特大地震影響技掏,放射性物質(zhì)發(fā)生泄漏铃将。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一哑梳、第九天 我趴在偏房一處隱蔽的房頂上張望劲阎。 院中可真熱鬧,春花似錦鸠真、人聲如沸悯仙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽锡垄。三九已至,卻和暖如春祭隔,著一層夾襖步出監(jiān)牢的瞬間货岭,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留千贯,地道東北人屯仗。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像丈牢,于是被迫代替她去往敵國(guó)和親祭钉。 傳聞我的和親對(duì)象是個(gè)殘疾皇子瞄沙,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345