Django 5 для начинающих

Прогресс по курсу:  9/1004

7.3 Доработки системы авторизации и регистрации, добавление сессий
3 из 4 шагов пройдено
0 из 5 баллов  получено

Доработки системы авторизации и добавление сессий

В этом шаге давайте доработаем систему добавив опцию «Запомнить меня» используя сессии и cookie.

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

В нашем блоге Django использует эту аутентификацию в своем модуле django.contrib.auth, конфигурация которого уже включена в settings.py, когда мы создавали нашу авторизацию в прошлом модуле.

Начнем с формы входа. В Django есть встроенный AuthenticationForm базовый класс для аутентификации пользователей на основе имени пользователя и пароля.

Мы создадим наш логин, расширив его, чтобы добавить флажок «Запомнить меня», а также загрузим поля имени пользователя и пароля с помощью виджетов, как мы видели в предыдущих шагах.

Опция «Запомнить меня» — это хорошая функция, поскольку пользователи не хотят вводить учетные данные каждый раз, когда посещают сайт.

Давайте в наши forms.py добавим форму авторизации:

from django.contrib.auth.forms import UserCreationForm, AuthenticationForm

class LoginForm(AuthenticationForm):
    username = forms.CharField(max_length=100,
                               required=True,
                               widget=forms.TextInput(attrs={'placeholder': 'Username'}))
    password = forms.CharField(max_length=50,
                               required=True,
                               widget=forms.PasswordInput(attrs={'placeholder': 'Password'}))
    remember_me = forms.BooleanField(required=False)

    class Meta:
        model = User
        fields = ['username', 'password', 'remember_me']


Далее давайте настроим представление для использования созданной нами формы входа. В Django также есть встроенный LoginView. Мы можем переопределить некоторые атрибуты и методы класса в соответствии с нашими потребностями.

В views.py добавим новый класс:

from django.contrib.auth.views import LoginView
from .forms import SignUpForm, LoginForm

class CustomLoginView(LoginView):
    form_class = LoginForm

    def form_valid(self, form):
        remember_me = form.cleaned_data.get('remember_me')

        if not remember_me:
            # Установим время истечения сеанса равным 0 секундам. Таким образом, он автоматически закроет сеанс после закрытия браузера. И обновим данные.
            self.request.session.set_expiry(0)
            self.request.session.modified = True

        # В противном случае сеанс браузера будет таким же как время сеанса cookie "SESSION_COOKIE_AGE", определенное в settings.py
        return super(CustomLoginView, self).form_valid(form)

 

  • Первое, что мы сделали, это установили form_class атрибут на наш пользовательский, LoginForm так как мы больше не используем значение по умолчанию AuthenticationForm.

  • Затем мы переопределяем form_valid метод, который вызывается при публикации действительных данных формы. Внутри этого метода мы проверяли, установлен ли флажок Remember_me.

  • Если этот флажок не установлен, сеанс автоматически истечет при закрытии браузера. Но для пользователей, которые установят флажок Remember_me, сеанс будет длиться до тех пор, пока мы определяем его в settings.py.


Итак, давайте продолжим и установим SESSION_COOKIE_AGE 30 дней (или столько, сколько вы хотите) в настройках settings.py:

SESSION_COOKIE_AGE = 60 * 60 * 24 * 30


Теперь внутри нашего urls.py давайте сопоставим желаемый маршрут входа с соответствующим CustomLoginView. И сразу давайте также создадим маршрут выхода из системы, который будет обрабатываться встроенным LogoutView:

from django.urls import path
from .views import SignUpView, CustomLoginView
from django.contrib.auth import views as auth_views


urlpatterns = [
    path("signup/", SignUpView.as_view(), name="signup"),
    path('login/', CustomLoginView.as_view(redirect_authenticated_user=True, template_name='registration/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'),
]

 

  • redirect_authenticated_user=True означает, что пользователи, пытающиеся получить доступ к странице входа после аутентификации, будут перенаправлены обратно.


Нам осталось только добавить шаблон выхода, а именно registration/logout.html.

У нас в settings.py добавлена строка:

LOGOUT_REDIRECT_URL = "/"

Она сделана для того, чтобы не использовать страницу после выхода пользователя, а делать редирект на главную страницу.

Давайте удалим ту строку, и добавим файл registration/logout.html следующего содержания:

{% extends "blog/base.html" %}
{% block title %}Logout{% endblock title %}
{% block content %}
    <div>
        <h5>You have been logged out</h5>
        <hr>
        <a href="{% url 'login' %}">Log In again</a>
    </div>
{% endblock content %}


Давайте проверим что мы сделали, запустим сервер и перейдем на страницу http://127.0.0.1:8000/accounts/login/:


Мы видим что на странице авторизации у нас появился checkbox Remember me:. И теперь после авторизации мы принимаем файл Cookie, проверим это:


Теперь попробуем выйти из системы:

И теперь мы видим сообщение о выходе.


И последнее в этом шаге, но не менее важное - лучше перенаправить пользователя на страницу входа после регистрации. Давайте добавим новый метод в нашу функцию SignUpView чтобы она приняла следующий вид:

class SignUpView(generic.CreateView):
    form_class = SignUpForm
    success_url = reverse_lazy("login")
    initial = None  # принимает {'key': 'value'}
    template_name = 'registration/signup.html'

    def dispatch(self, request, *args, **kwargs):
        # перенаправит на домашнюю страницу, если пользователь попытается получить доступ к странице регистрации после авторизации
        if request.user.is_authenticated:
            return redirect(to='/')

        return super(SignUpView, self).dispatch(request, *args, **kwargs)

    def get(self, request, *args, **kwargs):
        form = self.form_class(initial=self.initial)
        return render(request, self.template_name, {'form': form})

    def post(self, request, *args, **kwargs):
        form = self.form_class(request.POST)

        if form.is_valid():
            form.save()

            username = form.cleaned_data.get('username')
            messages.success(request, f'Account created for {username}')

            return redirect(to='login') # редирект на страницу логина после регистрации

        return render(request, self.template_name, {'form': form})


Мы добавили новую функцию dispatch, она проверит авторизован ли пользователь, если пользователь авторизован, то он не сможет попасть на страницу регистрации.

И ниже в методе post изменили redirect(to='login') при валидной форме отправки запроса. Чтобы после регистрации пользователь был отправлен на страницу авторизации.


  • Комментариев
Будьте вежливы и соблюдайте наши принципы сообщества. Пожалуйста, не оставляйте решения и подсказки в комментариях, для этого есть отдельный форум.
Оставить комментарий

Добрый день! Не совсем понял зачем мы передаем здесь LoginForm? Мы же уже сделали это, когда создавали кастомное представление.

Вот тут

@Александр_Матвеев, Здравствуйте. Поправили ошибку.

а почему мы не указали template_name в самом классе и зачем мы указали, что надо редиректить, ведь у нас класс наследуется, а до этого редирект был автоматически, тот который мы прописали в настройках, почему щас мы его пишем здесь?

 Строка:

path('login/', CustomLoginView.as_view(redirect_authenticated_user=True, template_name='registration/login.html'), name='login'),

@Шамбер_Егор,  и зачем нам добавлять этот путь path('logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout')  если он уже прописан базово accounts/logout/ [name='logout'] ?

@Шамбер_Егор, и ещё вы зачем-то импортировали  LoginForm в urls

@Шамбер_Егор, С помощью redirect_authenticated_user=True и template_name='registration/login.html в маршруте, хотели показать, что мы можем из маршрута отправлять параметры в представление.

Если мы будем использовать accounts/logout/ [name='logout'], то как мы свяжем наше представление с маршрутом? Поэтому мы и добавляем маршрут для нашего представления. LoginForm лишнее, убрал.

Хороший раздел, спасибо, много нового!

Хотел попробовать не переопределяя поля username и password на форме LoginForm установить виджеты через класс Meta, таким образом:

class LoginForm(AuthenticationForm):
    remember_me = forms.BooleanField(required=False)

    class Meta:
        model = get_user_model()
        fields = ['username', 'password', 'remember_me']
        widgets = {
            'username': forms.TextInput(attrs={'placeholder': 'Username'}),
            'password': forms.PasswordInput(attrs={'placeholder': 'Password'}),
        }

Но плейсхолдеры не установились, хотя с переопределением полей у меня все работает правильно. Как я понимал, эти способы равноценны. Не подскажете, почему это не срабатывает? Все-таки хочется по максимуму задействовать дефолтные свойства Django, переопределяя классы/поля/атрибуты/методы только тогда, когда это действительно необходимо.

Изменен ilya kutaev

@ilya_kutaev, Если честно, пока не понял, почему это не работает. Для обычных форм данный метод подходит, но они наследуются от forms.ModelForm, а эта от AuthenticationForm

В вашем случае можно сделать так:

class LoginForm(AuthenticationForm):
    remember_me = forms.BooleanField(required=False)

    class Meta:
        model = User
        fields = ['username', 'password', 'remember_me']

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['username'].widget.attrs.update({"placeholder": "Введите свой логин"})
        self.fields['password'].widget.attrs.update({"placeholder": "Введите свой пароль"})

@Илья_Перминов, Спасибо, видимо, дело именно в наследовании! 
В таком варианте проще становится переопределить поля )

Обязательно ли в super() прописывать наш класс, она же все равно вызывается из родительского класса, и то, что мы прописываем наш класс в нее, ни на что не влияет. Или я ошибаюсь?

@Никита_Айзиков, Не на что не влияет, будет в обоих вариантах работать, у меня просто привычка из Python 2 что обязательно нужно указывать.

Понял. Спасибо за ответ!

Обнаружил, что даже если пользователь авторизован, для отправления комментария надо ввести имя и почту. Можно ли добавить шаг с объяснением, как добиться простого комментирования зарегистрированным пользователям?

@Марат_Асылбаев, Это решается довольно легко, во вьюхе в функции post_comment добавим следующую логику, перед валидацией:

    comment_post = request.POST.copy()
    if request.user.is_authenticated:
        comment_post['name'] = request.user.username
        comment_post['email'] = request.user.email
    form = CommentForm(data=comment_post)

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

И в шаблоне меняем вывод полей:

{% if user.is_authenticated %}
            <h3>Add a new comment, {{ user.username }}</h3>
        {% else %}
            <h3>Add a new comment</h3>
            <div class="col mt-2">
                {{ form.name }}
            </div>
            <div class="col mt-2">
                {{ form.email }}
            </div>
        {% endif %}

@Илья_Перминов, Не могли бы вы пояснить изменения в шаблоне? Не смог найти способ изменить текущий шаблон - как я понял, речь о comment_form.html

@ilya_kutaev, Весь код шаблона comment_form.html

<form action="{% url "blog:post_comment" post.id %}" method="post">
    {% csrf_token %}

    {% if user.is_authenticated %}
        <h3>Add a new comment, {{ user.username }}</h3>

    {% else %}
        <h3>Add a new comment</h3>
        <div class="col mt-2">
            {{ form.name }}
        </div>
        <div class="col mt-2">
            {{ form.email }}
        </div>
    {% endif %}

        <div class="col mt-2">
            {{ form.body }}
        </div>
    <p><input type="submit" value="Add comment"></p>
</form>

Первым делом мы проверяем авторизован ли пользователь, если авторизован, то в заголовке пишем Add a new comment, {{ user.username }}, и поле body комментария, а name и email мы возьмем из вьюхи. А в случае если он не авторизован, то выводим полную форму, с полями nameemail и body.  Чуть разбил код, чтобы было понятнее.

@Илья_Перминов, Стало лучше, для авторизованного пользователя работает корректно:

А вот для неавторизованного пользователя не отображаются метки полей:

Дополнил CommentForm следующим образом:

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ['name', 'email', 'body']
        widgets = {
            'name': forms.TextInput(attrs={'placeholder': 'Username'}),
            'email': forms.PasswordInput(attrs={'placeholder': 'Email'}),
            'body': forms.Textarea(attrs={'placeholder': 'Текст комментария'}),
        }

Здесь, в отличие от LoginForm, widgets в Meta отрабатывает корректно:

Спасибо!

Всё правильно, я просто тестировал на проекте с бутсрапом уже.

как в Джанго работает вызов функций классов? после получения HTTP запроса, конкретный класс представления прогоняет запрос через все функции этого класса представления? то есть сначала будет вызвана функция dispatch, если они ничего не вернет будет вызвана функция get и т.д.?

@Виктор_Русинович, сначала вызывается метод dispatch, если пользователь не авторизован, то вызывается метод dispatch из родительского класса CreateView, который определяет какой метод вызывать при GET или POST запросах.
Вот исходный код метода dispatch из класса CreateView:

def dispatch(self, request, *args, **kwargs):
    # Try to dispatch to the right method; if a method doesn't exist,
    # defer to the error handler. Also defer to the error handler if the
    # request method isn't on the approved list.
    if request.method.lower() in self.http_method_names:
        handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
    else:
        handler = self.http_method_not_allowed
    return handler(request, *args, **kwargs)

Примечание: в последнем примере мы в классе SignUpView переопределяли метод dispatch. Возникает резонный вопрос: почему именно этот метод, а не другой? Насколько я понял дело вот в чем:

Обращаясь к исходному, коду видим это:

def dispatch(self, request, *args, **kwargs):
    # Try to dispatch to the right method; if a method doesn't exist,
    # defer to the error handler. Also defer to the error handler if the
    # request method isn't on the approved list.
    if request.method.lower() in self.http_method_names:
        handler = getattr(
            self, request.method.lower(), self.http_method_not_allowed
        )
    else:
        handler = self.http_method_not_allowed
    return handler(request, *args, **kwargs)

То есть, если я правильно понял, этот метод ответственен за вызов правильного метода-обработчика. По умолчанию http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] и еще есть соответствующие методы: get, post, options и т.д., также мы можем это переопределить, чтобы запретить какой либо метод. Получается, мы делаем if request.user.is_authenticated: ... здесь как раз для того, чтобы класс еще не успел начать обработку какого-либо метода.

Изменен Кирилл Семенихин

@Кирилл_Семенихин, да, все верно. Сначала вызывается наш метод dispatch, если пользователь не авторизован, то вызывается метод dispatch из родительского класса CreateView, который определяет какой метод вызывать при GET или POST запросах.

Вопрос.
LoginForm и CustomLoginView прям скопипастил, но галочка - remember_me, у меня так и не появилось. Ощущение, будто бы переменная form_class не переопределилась и берется из LoginView. Такое возможно?

@Alex, а в маршруты добавили?

    path('login/', CustomLoginView.as_view(redirect_authenticated_user=True, template_name='registration/login.html'), name='login'),

@Илья_Перминов, да. Все есть.

@Alex, можете загрузить сюда архив с вашим проектом. Попробую разобраться.

@Илья_Перминов, готово

@Alex, в главном urls.py маршрутов у вас ошибка.

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls', namespace='blog')),
    path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
    path("accounts/", include("django.contrib.auth.urls")),
    path("accounts/", include("accounts.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

То есть у вас первым делом используются стандартные маршруты авторизации django, и если они не найдены, тогда уже наши кастомные методы. А должно быть наоборот. Поменяйте код, на следующий:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls', namespace='blog')),
    path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
    path("accounts/", include("accounts.urls")),
    path("accounts/", include("django.contrib.auth.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Django будет искать шаблоны URL-адресов сверху вниз, поэтому, когда он увидит маршрут URL-адреса http://127.0.0.1:8000/accounts/, то он будет следовать сначала в наше accounts приложение, и только потом в auth.

Изменен Илья Перминов
Спасибо!!