Посібник по Django для початківців – Частина 4

2 хв. читання

Основи

Цей урок ввесь буде присвячений системі автентифікації Django. Ми збираємося реалізувати усі необхідні речі: реєстрацію, вхід, вихід, відновлення та зміну паролю.

Ви також дізнаєтеся, як захистити представлення від неавторизованих користувачів та як отримати доступ до інформації зареєстрованого користувача.

У наступному розділі ви знайдете декілька каркасів, пов'язаних з автентифікацією сторінок, які ми збираємося реалізувати в цьому уроці. Після цього ми почнемо налаштовувати новий Django застосунок. Досі ми працювали над застосунком під назвою boards. Але всі речі, пов'язані з автентифікацією, можуть знаходитись в іншому застосунку, щоб досягти кращої організації коду.

Посібник по Django для початківців – Частина 4


Каркаси

Нам потрібно оновити каркаси застосунку. Спершу додамо нові опції до головного меню. Якщо користувач не автентифікований, у нас повинно бути дві кнопки: зареєструватись (sign up) та увійти (log in).

Посібник по Django для початківців – Частина 4
Рисунок 1. Головне меню для неавтентифікованих користувачів.

Якщо користувач автентифікований, то замість цього нам потрібно показувати його ім'я разом зі спадним меню (dropdown menu), у якому буде три опції: мій акаунт (my account), змінити пароль (change password) та вийти (log out).

Посібник по Django для початківців – Частина 4
Рисунок 2. Головне меню для автентифікованих користувачів

На сторінці входу нам потрібна форма з ім'ям користувача та паролем, кнопка з головною дією (вхід) та два альтернативних шляхи: сторінка реєстрації та відновлення паролю.

Посібник по Django для початківців – Частина 4
Рисунок 3. Сторінка входу

На сторінці реєстрації нам потрібна форма з чотирма полями: ім'я користувача (username), адреса електронної пошти (email), пароль (password) та підтвердження паролю (confirm password). Користувач також повинен мати доступ до сторінки входу.

Посібник по Django для початківців – Частина 4
Рисунок 4. Сторінка реєстрації

На сторінці відновлення паролю у нас буде форма лише з адресою електронної пошти.

Посібник по Django для початківців – Частина 4
Рисунок 5. Сторінка відновлення паролю

Потім, натиснувши на спеціальне посилання на токен, користувач буде перенаправлений на сторінку, де він зможе встановити новий пароль.

Посібник по Django для початківців – Частина 4
Рисунок 6. Зміна паролю

Початкове встановлення

Щоб керувати всією цією інформацією, ми можемо розбити її в іншому застосунку. У корені проекту, на тій же сторінці, де знаходиться скрипт manage.py, запустіть наступну команду, щоб розпочати новий застосунок.

django-admin startapp accounts

Структура проекту зараз повинна виглядати якось так:

myproject/
 |-- myproject/
 |    |-- accounts/     <-- наш новий застосунок Django!
 |    |-- boards/
 |    |-- myproject/
 |    |-- static/
 |    |-- templates/
 |    |-- db.sqlite3
 |    +-- manage.py
 +-- venv/

Наступним кроком у файл settings.py до INSTALLED_APPS додайте застосунок accounts:

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.


Реєстрація

Посібник по Django для початківців – Частина 4

Почнімо зі створення представлення реєстрації. Спершу створіть новий маршрут у файлі 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),
]

Зверніть увагу на те, як ми імпортуємо модуль views зі застосунку accounts іншим способом.

from accounts import views as accounts_views

Ми даємо йому псевдонім, тому що інакше він зіткнувся б з представленнями boards. Пізніше ми зможемо вдосконалити дизайн urls.py. Але поки що зосередимося на функціях автентифікації.

Тепер всередині застосунку accounts відредагуйте 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 %}

Відкрийте у браузері URL http://127.0.0.1:8000/signup/ та перевірте, чи все працює:

Посібник по Django для початківців – Частина 4

Час для написання тестів:

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 = успіх), і якщо 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'...

Для представлень автентифікації (реєстрація, вхід, відновлення паролю тощо) ми не будемо використовувати верхню панель або рядок навігації. Ми все ще можемо використовувати шаблон 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 content %}, ми можемо використати {% block body %}.

templates/signup.html

{% extends 'base.html' %}

{% block body %}accounts/views.py
  <h2>Sign up</h2>
{% endblock %}

Посібник по Django для початківців – Частина 4

Посібник по Django для початківців – Частина 4

Час створити форму реєстрації. 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 %}

Посібник по Django для початківців – Частина 4

Виглядає трохи безладно, чи не так? Ми можемо використати наш шаблон 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 %}

Посібник по Django для початківців – Частина 4

Ух, майже у цілі. Наша форма form.html частинно є шаблоном, який відображає деякий необроблений HTML. Це функція безпеки. За замовчуванням Django обробляє всі рядки як небезпечні, уникаючи всіх спеціальних символів, які можуть викликати проблеми. Але в цьому випадку ми можемо йому довіряти.

templates/includes/form.html

{% load widget_tweaks %}

{% for field in form %}
  <div class="form-group">
    {{ field.label_tag }}

    <!-- код обмежений для стислості -->

    {% if field.help_text %}
      <small class="form-text text-muted">
        {{ field.help_text|safe }}  <!-- новий код тут -->
      </small>
    {% endif %}
  </div>
{% endfor %}

У минулому шаблоні ми в основному додали опцію safe до field.help_text: {{ field.help_text|safe }}.

Збережіть файл form.html та знову перевірте сторінку реєстрації:

Посібник по Django для початківців – Частина 4

Реалізуймо бізнес логіку представлення 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 на auth_login, але пізніше усвідомив, що Django 1.11 має основане на класі представлення для представлення входу, LoginView, тому нема ніякого ризику зіткнення імен. У старіших версіях були auth.login та auth.view.login, які викликали плутанину, тому що одна була функцією, призначеною для входу користувача, а інша — представленням. Коротко кажучи: ви можете імпортувати її просто як login, якщо хочете. Це не спричинить ніяких проблем.

Якщо форма дійсна, то за допомогою user = form.save() створиться екземпляр **User **. Створений користувач передасться як аргумент функції auth_login, вручну автентифікуючи користувача.

Спробуймо. Спершу відправимо деякі недійсні дані або пусту форму, невідповідні поля, або наявне ім'я користувача:

Посібник по Django для початківців – Частина 4

Тепер заповніть форму та відправте її. Перевірте, чи новий користувач створився та перейшов на домашню сторінку.

Посібник по Django для початківців – Частина 4

Посилання на автентифікованого користувача в шаблоні

Як ми можемо дізнатися, чи це спрацювало? Що ж, ми можемо відредагувати шаблон 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 %}

Посібник по Django для початківців – Частина 4

Тестування представлення реєстрації

Вдосконалимо тепер наші тест-кейси:

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)

Ми трохи змінили клас SignUpTests. Визначили метод setUp, перемістили туди об'єкт відповіді. Потім ми також перевіряємо, чи є у відповіді форма й токен CSRF.

Зараз ми протестуємо вдалу реєстрацію. Цього разу, для кращої організації тестів, створімо новий клас:

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):
    # код обмежений...

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):
        '''
        Дійсна форма повинна перенаправляти користувача на домашню сторінку
        '''
        self.assertRedirects(self.response, self.home_url)

    def test_user_creation(self):
        self.assertTrue(User.objects.exists())

    def test_user_authentication(self):
        '''
         Створіть новий запит на довільну сторінку.
Після успішної реєстрації отримана відповідь
тепер має "user" у своєму контексті.
        '''
        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):
    # код обмежений...

class SuccessfulSignUpTests(TestCase):
    # код обмежений...

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):
        '''
        Недійсна форма повинна повертати на ту саму сторінку
        '''
        self.assertEquals(self.response.status_code, 200)

    def test_form_errors(self):
        form = self.response.context.get('form')
        self.assertTrue(form.errors)

    def test_dont_create_user(self):
        self.assertFalse(User.objects.exists())

Додавання до форми поля з email

Все працює, але... Поле з email відсутнє. Що ж, UserCreationForm не надає поле для email. Але ми можемо його розширити.

Створіть файл з назвою forms.py всередині теки accounts:

accounts/forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User

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

Тепер, замість того, щоб використовувати UserCreationForm в нашому views.py, імпортуємо нову форму 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})

Лише з цією невеличкою зміною все вже працює:

Посібник по Django для початківців – Частина 4

Не забудьте змінити тест-кейс, щоб використовувати 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):
        '''
        Представлення має містити п'ять введень: 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, краще трохи покращимо дизайн проекту.

Створімо нову теку з назвою tests всередині теки accounts. А всередині теки tests створіть новий файл під назвою init.py.

Тепер перемістіть файл tests.py до теки tests та перейменуйте його в test_view_signup.py.

Остаточний результат повинен бути наступним:

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

Зауважте, оскільки ми використовуємо відносне імпортування в контексті застосунків, нам треба виправити імпортування в новому test_view_signup.py:

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, щоб додати прізвище та ім'я користувача, нам, можливо, знадобиться виправити декілька тест-кейсів, навіть якщо ми нічого не зламаємо.

Посібник по Django для початківців – Частина 4

Ці сповіщення корисні, тому що вони допомагають підвищити рівень інформованості, особливо для новачків, які мають справу з програмуванням вперше в житті. Це допомагає їм програмувати з упевненістю.

Покращення шаблону реєстрації

Попрацюймо трохи над ним. Тут ми можемо використовувати картки Bootstrap 4, щоб зробити його презентабельним.

Перейдіть на https://www.toptal.com/designers/subtlepatterns/ та знайдіть хороший фоновий малюнок, щоб використати його в якості бекграунду на сторінках акаунтів. Завантажте його, створіть нову теку за назвою img всередині теки static та помістіть туди зображення.

Після цього в static/css створіть новий CSS файл під назвою accounts.css. Результат має бути наступним:

myproject/
 |-- myproject/
 |    |-- accounts/
 |    |-- boards/
 |    |-- myproject/
 |    |-- static/
 |    |    |-- css/
 |    |    |    |-- accounts.css  <-- тут
 |    |    |    |-- app.css
 |    |    |    +-- bootstrap.min.css
 |    |    +-- img/
 |    |    |    +-- shattered.png  <-- тут (ім'я може відрізнятися, в залежності від того, який малюнок ви завантажили)
 |    |-- templates/
 |    |-- db.sqlite3
 |    +-- manage.py
 +-- venv/

Тепер відредагуйте файл accounts.css:

static/css/accounts.css

body {
 background-image: url(../img/shattered.png);
}

.logo {
 font-family: 'Peralta', cursive;
}

.logo a {
 color: rgba(0,0,0,.9);
}

.logo a:hover,
.logo a:active {
 text-decoration: none;
}

Ми можемо змінити шаблон signup.html, щоб використовувати новий CSS, а також карти Bootstrap 4:

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

З цим наша сторінка реєстрації зараз має виглядати так:

Посібник по Django для початківців – Частина 4


Вихід з акаунту

Щоб зберегти природний потік реалізації, додамо представлення виходу з акаунту. Спершу відредагуйте 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),
]

Ми імпортували views із модуля Django contrib. Перейменували його в auth_views, щоб уникнути зіткнень з boards.views. Зверніть увагу, що це представлення трохи інше: LogoutView.as_view(). Це представлення Django на основі класу. До цього часу класи у нас були реалізовані тільки як функції Python. Представлення на основі класу надають більш гнучкий спосіб розширення й повторного використання представлень. Трохи пізніше ми більш детально про це поговоримо.

Відкрийте файл settings.py та додайте в кінець зміну LOGOUT_REDIRECT_URL:

myproject/settings.py

LOGOUT_REDIRECT_URL = 'home'

Тут ми передаємо ім'я патерну URL, який ми хочемо перенаправити користувачеві після виходу з акаунту.

Просто відкрийте 127.0.0.1:8000/logout/, і ви вийдете з акаунту. Але затримаймось на секунду. Перед тим, як вийти, створімо спочатку спадне меню для авторизованих користувачів.


Відображення меню для автентифікованих користувачів

Тепер нам потрібно буде зробити деякі налаштування у шаблоні base.html. Ми повинні додати спадне меню із посиланням на вихід з акаунту.

Спадний компонент Bootstrap 4 для роботи потребує jQuery.

Спочатку перейдіть на jquery.com/download/ та завантажте стиснуту продакшн версію jQuery 3.2.1(compressed, production jQuery 3.2.1).

Посібник по Django для початківців – Частина 4

Всередині теки static створіть нову теку під назвою js. Скопіюйте туди файл jquery-3.2.1.min.js.

Bootstrap 4 для роботи також потребує бібліотеку Popper. Перейдіть на popper.js.org та завантажте останню версію.

Всередині теки popper.js-1.12.5 перейдіть до dist/umd та скопіюйте файл popper.min.js в теку js.

Якщо у вас більше немає файлів Bootstrap 4, завантажте його знову з http://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 %}
    <!-- код обмежений для стислості -->
    {% endblock body %}
    <script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
    <script src="{% static 'js/popper.min.js' %}"></script>
    <script src="{% static 'js/bootstrap.min.js' %}"></script>
  </body>
</html>

Якщо інструкції здаються вам заплутаними, просто завантажте файли, використовуючи прямі посилання нижче:

Правою кнопкою миші й Зберегти як....

Тепер ми можемо додати спадне меню Bootstrap 4:

templates/base.html

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

Посібник по Django для початківців – Частина 4

Спробуймо. Натисніть на вихід з акаунту: Посібник по Django для початківців – Частина 4

Працює. Але спадне меню відображається незалежно від того, увійшов користувач, чи ні. Різниця в тому, що тепер ім'я користувача порожнє, і ми бачимо тільки стрілку.

Ми можемо це виправити:

<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 відображати спадне меню тільки тоді, коли користувач авторизований, а в іншому випадку — показувати кнопки входу та реєстрації:

Посібник по Django для початківців – Частина 4


Вхід до акаунту

Спершу додайте новий 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 %}

Посібник по Django для початківців – Частина 4

І ми повторюємо 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 %}

У нас досі немає URL-адреси для зміни пароля, тому наразі залиште на її місці #.

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

Зауважте, що ми додали URL-адресу для входу: <a href="{% url 'login' %}">Log in</a>.

Загальні помилки входу до акаунту (non field errors)

Якщо ми надішлемо порожню форму для входу, то отримаємо такі чудові повідомлення про помилки:

Посібник по Django для початківців – Частина 4

Але якщо ми надішлемо ім'я користувача, що не існує, або недійсний пароль, то ось, що відбудеться:

Посібник по Django для початківців – Частина 4

Трохи оманливо. Поля підсвічуються зеленим кольором, натякаючи на те, що вони в порядку. Також нема ніякого повідомлення, яке показує, що щось не так.

Це тому, що форми мають спеціальний тип помилок, який називається non-field errors. Це набір помилок, не пов'язаних з певним полем. Зробімо рефакторинг підшаблону form.html, щоб ці помилки відображалися також.

templates/includes/form.html

{% load widget_tweaks %}

{% if form.non_field_errors %}
  <div class="alert alert-danger" role="alert">
    {% for error in form.non_field_errors %}
      <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
    {% endfor %}
  </div>
{% endif %}

{% for field in form %}
  <!-- код обмежений -->
{% endfor %}

{% If forloop.last %} — лише незначна річ. Тому що тег p має margin-bottom. І форма може мати декілька неотриманих помилок. Для кожної такої помилки ми рендеримо тег p з помилкою. Тоді я перевіряю, чи остання це помилка для рендерингу. Якщо так, ми додаємо CSS клас Bootstrap 4 mb-0, який означає "margin bottom = 0". З деяким додатковим простором, попередження не виглядатиме дивно. Знову ж таки, дуже незначна деталь. Я це зробив, щоб зберегти послідовність інтервалів.

Посібник по Django для початківців – Частина 4

Не дивлячись на це, нам все одно доведеться мати справу з полем для пароля. Річ у тому, що Django ніколи не повертає дані з поля для пароля клієнту. Тому, замість того, щоб намагатися зробити щось розумне, просто будемо ігнорувати CSS класи is-valid та is-invalid в деяких випадках. Але наш шаблон форми вже виглядає дуже складним. Ми можемо перемістити частину коду в template tag..

Створення власних тегів шаблонів

Всередині застосунку boards створіть нову теку під назвою templatetags. Потім, всередині цієї теки створіть два пустих файли __init__.py та form_tags.py.

Структура має бути наступною:

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

Створімо два тега шаблонів у файлі form_tags.py:

boards/templatetags/form_tags.py

from django import template

register = template.Library()

@register.filter
def field_type(bound_field):
   return bound_field.field.widget.__class__.__name__

@register.filter
def input_class(bound_field):
   css_class = ''
   if bound_field.form.is_bound:
       if bound_field.errors:
           css_class = 'is-invalid'
       elif field_type(bound_field) != 'PasswordInput':
           css_class = 'is-valid'
   return 'form-control {}'.format(css_class)

Це фільтри шаблонів. Вони працюють так:

Спочатку ми завантажуємо їх у шаблон, як ми робили з тегами шаблонів widget_tweaks або static. Зверніть увагу, що після створення цього файлу, вам треба вручну зупинити сервер розробки та запустити його знову, для того, щоб Django міг ідентифікувати нові теги шаблонів.

{% load form_tags %}

Після цього ми вже можемо використовувати їх у шаблоні:

{{ form.username|field_type }}

Що поверне:

'TextInput'

Або у випадку input_class:

{{ form.username|input_class }}

<!-- якщо форма не пов'язана, вона просто повернеться: -->
'form-control '

<!-- якщо форма пов'язана та дійсна: -->
'form-control is-valid'

<!-- якщо форма пов'язана та недійсна: -->
'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 %}

Набагато краще, чи не так? Знизилась складність шаблону. Він тепер виглядає чистіше. І також вирішалась проблема з зеленою рамкою навколо поля для введення паролю:

Посібник по Django для початківців – Частина 4

Тестування тегів шаблонів

Спочатку організуймо трохи тести boards. Як ми робили з accounts, створімо нову теку з назвою tests, додамо init.py, скопіюємо tests.py та поки що перейменуємо його в test_views.py.

Додайте новий пустий файл з назвою test_templatetags.py:

myproject/
 |-- myproject/
 |    |-- accounts/
 |    |-- boards/
 |    |    |-- migrations/
 |    |    |-- templatetags/
 |    |    |-- tests/
 |    |    |    |-- __init__.py
 |    |    |    |-- test_templatetags.py  <-- новий файл, поки що пустий
 |    |    |    +-- test_views.py  <-- наш старий файл зі всіма тестами
 |    |    |-- __init__.py
 |    |    |-- admin.py
 |    |    |-- apps.py
 |    |    |-- models.py
 |    |    +-- views.py
 |    |-- myproject/
 |    |-- static/
 |    |-- templates/
 |    |-- db.sqlite3
 |    +-- manage.py
 +-- venv/

Виправимо імпортування test_views.py:

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()  # незв'язана форма
        self.assertEquals('form-control ', input_class(form['name']))

    def test_valid_bound_field(self):
        form = ExampleForm({'name': 'john', 'password': '123'})  # пов'язана форма (поле + дані)
        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'})  # пов'язана форма (поле + дані)
        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 для перевірки правильності надсилання листів.

Посібник по Django для початківців – Частина 4

Бекенд для відправлення електронної пошти (Console Email Backend)

Ідея полягає в тому, що під час розробки проекту, замість того, щоб надсилати справжні електронні листи, ми просто реєструємо їх. Є дві опції: записати всі листи в файл або просто відобразити їх у консолі. Я вважаю останній варіант більш зручним, тому що ми вже використовуємо консоль для запуску серверу розробки й налаштування.

Відредагуйте модуль 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 є наступні файли шаблонів:

  • password_reset.html
  • password_reset_email.html: цей шаблон є тілом повідомлення, яке надсилається користувачеві
  • password_reset_subject.txt: цей шаблон є полем теми повідомлення. Він має бути файлом з єдиним рядком.
  • password_reset_done.html
  • password_reset_confirm.html
  • password_reset_complete.html

Перш ніж розпочнемо реалізовувати шаблони, підготуємо спочатку новий тестовий файл.

Ми можемо тільки додати деякі базові тести, тому що ці представлення та форми вже протестовані у коді Django. Ми збираємося протестувати тільки специфіку нашого застосунку.

Всередині теки accounts/tests створіть новий тестовий файл з назвою test_view_password_reset.py:

Представлення Password Reset

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

Посібник по Django для початківців – Частина 4

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):
        '''
        Це представлення містить два введення: 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):
        '''
        Дійсна форма повинна перенаправити користувача до представлення `password_reset_done`
        '''
        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):
        '''
        Навіть недійсна електронна пошта у БД має перенаправляти користувача до представлення `password_reset_done`
        '''
        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

Посібник по Django для початківців – Частина 4

Ми можемо створити конкретний файл для тестування адреси електронної пошти. Створіть новий файл з назвою test_mail_password_reset.py всередині теки accounts/tests:

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)

Цей тест бере надіслану застосунком електронну пошту та досліджує тему, вміст тіла й те, кому був відправлений цей лист

Представлення Password Reset Done

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

Посібник по Django для початківців – Частина 4

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

Представлення Password Reset Confirm

templates/password_reset_confirm.html

{% extends 'base_accounts.html' %}

{% block title %}
  {% if validlink %}
    Change password for {{ form.user.username }}
  {% else %}
    Reset your password
  {% endif %}
{% endblock %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8 col-sm-10">
      <div class="card">
        <div class="card-body">
          {% if validlink %}
            <h3 class="card-title">Change password for @{{ form.user.username }}</h3>
            <form method="post" novalidate>
              {% csrf_token %}
              {% include 'includes/form.html' %}
              <button type="submit" class="btn btn-success btn-block">Change password</button>
            </form>
          {% else %}
            <h3 class="card-title">Reset your password</h3>
            <div class="alert alert-danger" role="alert">
              It looks like you clicked on an invalid password reset link. Please try again.
            </div>
            <a href="{% url 'password_reset' %}" class="btn btn-secondary btn-block">Request a new password reset link</a>
          {% endif %}
        </div>
      </div>
    </div>
  </div>
{% endblock %}

Доступ до цієї сторінки можна отримати лише за допомогою посилання, надісланого в електронному листі. Це виглядає так: http://127.0.0.1:8000/reset/Mw/4po-2b5f2d47c19966e294a1/

На етапі розробки візьміть це посилання з консолі.

Якщо воно дійсне:

Посібник по Django для початківців – Частина 4

Або, якщо воно вже було використане:

Посібник по Django для початківців – Частина 4

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
from django.test import TestCase


class PasswordResetConfirmTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')

        '''
        створіть дійсний токен для скидання пароля
        виходячи з того, як django створює токен внутрішньо:
        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):
        '''
        Представлення має містити два введення: 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)

        '''
        зробити токен недійсним, змінивши пароль
        '''
        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))

Представлення Password Reset Complete

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

Посібник по Django для початківців – Частина 4

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

Представлення зміни паролю

Це представлення призначене для використання авторизованими користувачами, які бажають змінити свій пароль. Зазвичай, ці форми складаються з трьох полів: старий пароль, новий пароль та підтвердження нового паролю.

myprojects/urls.py (продивитись повний зміст файлу)

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

Ці представлення працюють тільки для авторизованих користувачів. Вони використовують декоратор представлень з назвою @login_required. Цей декоратор запобігає отриманню доступу до цієї сторінки неавторизованим користувачам. Якщо користувач не авторизований, Django перенаправляє його на сторінку входу в акаунт.

Зараз нам треба визначити в settings.py, яка URL-адреса для входу в акаунт у нашому застосунку:

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

Посібник по Django для початківців – Частина 4

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

Посібник по Django для початківців – Частина 4

Що стосується представлення зміни паролю, ми можемо реалізувати аналогічні тест кейси, як ми вже робили. Створіть новий файл тестування з назвою test_view_password_change.py.

Нижче наведені нові типи тестів. Ви можете переглянути всі тести, які я написав для представлення зміни паролю, натиснувши продивитись повний зміст файлу біля фрагмента коду. Більшість тестів аналогічні до тих, які ми писали досі. Я перемістив їх у зовнішній файл, щоб уникнути повторюваності.

accounts/tests/test_view_password_change.py (продивитись повний зміст файлу)

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

Тест зверху намагається отримати доступ до представлення password_change входу в акаунт. Очікувана поведінка полягає в тому, щоб перенаправити користувача на сторінку входу.

accounts/tests/test_view_password_change.py (продивитись повний зміст файлу)

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

Тут ми визначили новий клас з назвою PasswordChangeTestCase. Він виконує базове налаштування, створюючи нового користувача й роблячи запит POST до представлення password_change. В наступному наборі тестових випадків ми використаємо цей клас замість класу TestCase та протестуємо успішний та недійсний запити:

accounts/tests/test_view_password_change.py (продивитись повний зміст файлу)

class SuccessfulPasswordChangeTests(PasswordChangeTestCase):
    def setUp(self):
        super().setUp({
            'old_password': 'old_password',
            'new_password1': 'new_password',
            'new_password2': 'new_password',
        })

    def test_redirection(self):
        '''
        Правильне подання форми повинно переадресовувати користувача
        '''
        self.assertRedirects(self.response, reverse('password_change_done'))

    def test_password_changed(self):
        '''
        Оновити екземпляр користувача з бази даних, щоб отримати новий
хеш паролю, оновлений з допомогою представлення зміни паролю
        '''
        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password('new_password'))

    def test_user_authentication(self):
        '''
        Створіть новий запит на довільну сторінку.
 Після успішної реєстрації отримана відповідь тепер має "user" у своєму контексті.
        '''
        response = self.client.get(reverse('home'))
        user = response.context.get('user')
        self.assertTrue(user.is_authenticated)


class InvalidPasswordChangeTests(PasswordChangeTestCase):
    def test_status_code(self):
        '''
        Недійсна форма має повертати на ту саму сторінку
        '''
        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):
        '''
        Оновіть екземпляр користувача, щоб впевнитись, що у нас
        є найсвіжіші дані
        '''
        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password('old_password')

Метод refresh_from_db() дає переконатись, що у нас є останній стан даних. Він змушує Django знову запитувати базу даних для оновлення даних. Ми маємо це зробити, тому що представлення change_password оновлює пароль в базі даних. Тому, щоб перевірити, чи дійсно пароль змінився, нам треба взяти останні дані з бази даних.


Висновки

Автентифікація — дуже поширений варіант використання Django застосунків. У цьому уроці ми реалізували всі важливі представлення: реєстрація, вхід та вихід з акаунту, скидання паролю та його зміна. Тепер, коли у нас є спосіб створення користувачів та їх автентифікації, ми зможемо продовжити розробку інших представлень нашого застосунку.

Нам все ще потрібно реалізувати багато речей відносно дизайну коду: тека з шаблонами перетворюється в безлад з такою кількістю файлів. Тести застосунку boards все ще неорганізовані. Також нам потрібно розпочати робити рефакторинг представлення new topic, оскільки тепер у нас є можливість розпізнавати авторизованих користувачів. Скоро ми перейдемо до цієї частини.

Сподіваюсь, вам сподобалась четверта частина цієї навчальної серії! Вихідний код проекту доступний на GitHub. Поточний стан проекту можна знайти за тегом випуску v0.4-lw. Наведене нижче посилання приведе вас до потрібного місця:

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

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація