🐍🎸 Курс Django: Сложная форма с кастомной капчей

Делаем анкету с различными виджетами, защищаем форму с помощью капчи, автоматически отправляем данные и вложенные файлы на email.

Обзор проекта

Анкета кандидата

Анкета кандидата – форма с выпадающим списком, чекбоксами, радиокнопками, полем для загрузки файла резюме и капчей. Основная функциональность:

  • Кастомный рендеринг – все поля анкеты выводятся по отдельности, что представляет собой определенную сложность при обработке выпадающего списка, радиокнопок и чекбоксов.
  • Кастомная капча и ее обновление без перезагрузки страницы.
  • Автоматическая отправка полученных данных (включая файл резюме) на нужный email.

Модели

Три поля в форме – «Вакансия», «Сертификаты» и «Коммерческий опыт» имеют список возможных ответов.

1. Сертификатов может быть несколько, или ни одного – для хранения множественных ответов в базе и их вывода в виде чекбоксов принято использовать поле и связь ManyToManyField:

class Certificate(models.Model):
    name = models.CharField(max_length=100)
...

class Application(models.Model):
...
    certificates = models.ManyToManyField(Certificate, blank=True)

2. Вакансии выводятся в виде раскрывающегося списка. В БД такие записи хранят в виде choices, которые, в свою очередь, определяются с помощью кортежей:

class Certificate(models.Model):
...
    POSITIONS = [
        ('frontend', 'Frontend разработчик'),
        ('backend', 'Backend разработчик'),
        ('mobile', 'Mobile разработчик'),
        ('devops', 'DevOps инженер'),
        ('qa', 'Тестировщик'),
    ]
    position = models.CharField(max_length=100, choices=POSITIONS, default='frontend')   
...    

3. Коммерческий опыт предполагает только два варианта ответа – либо он есть, либо его нет. Поэтому для хранения этих choices используется BooleanField:

class Application(models.Model):
...
    COMMERCIAL_EXPERIENCE_CHOICES = [
        (True, 'Есть'),
        (False, 'Нет')
    ]
    commercial_experience = models.BooleanField(choices=COMMERCIAL_EXPERIENCE_CHOICES, default=True)
...

4. Для загрузки файла резюме используется поле FileField. Django автоматически загружает файлы пользователей в поддиректорию media:

resume = models.FileField(upload_to='resumes/')

5. Информация, которую возвращает о себе модель Application, будет указана в email'e:

def __str__(self):
    POSITIONS_DICT = dict(self.POSITIONS)
    return f"{self.name} с образованием {self.education} прислал резюме на вакансию {POSITIONS_DICT.get(self.position)}"

Форма

В форме мы используем все поля модели Application, плюс добавляем поле для капчи (подробнее о капче ниже). Здесь же определяем минимальное и максимальное значение для поля «Возраст», а также используем встроенный валидатор FileExtensionValidator для определения допустимых форматов файла резюме:

class ApplicationForm(forms.ModelForm):
    captcha = CaptchaField()
    age = forms.IntegerField(min_value=18, max_value=35)
    resume = forms.FileField(
    validators=[FileExtensionValidator(allowed_extensions=['doc', 'docx', 'pdf'])],
    error_messages={'invalid_extension': 'Файл должен иметь формат DOC, DOCX или PDF'} 
)

В классе Meta определяем тип виджетов и устанавливаем значение по умолчанию empty_label для выпадающего списка. Если такое значение не установить, в верхнем поле списка будет выведен пунктир --------.

widgets = {
'position' : forms.Select(attrs={'empty_label': 'Frontend разработчик'}),
'commercial_experience': forms.RadioSelect(),
'certificates': forms.CheckboxSelectMultiple()
}

Для передачи текста плейсхолдеров, стиля form control и установления высоты текстовых полей переопределяем значения нужных атрибутов:

def __init__(self, *args, **kwargs):
    super(ApplicationForm, self).__init__(*args, **kwargs)
    self.fields['name'].widget.attrs['placeholder'] = 'Введите ваше имя'
    self.fields['age'].widget.attrs['placeholder'] = 'Мы рассматриваем кандидатов от 18 до 35'
    self.fields['education'].widget.attrs['placeholder'] = 'Колледж, вуз, курсы'
    self.fields['education'].widget.attrs['rows'] = 3
    self.fields['email'].widget.attrs['placeholder'] = 'Введите корректный email для связи'
    self.fields['work_experience'].widget.attrs['placeholder'] = 'За последние 5 лет'
    self.fields['work_experience'].widget.attrs['rows'] = 3
    self.fields['projects'].widget.attrs['placeholder'] = 'Ссылки на ваши проекты'
    self.fields['projects'].widget.attrs['rows'] = 3
    self.fields['resume'].widget.attrs['accept'] = '.pdf,.doc,.docx'     
    for name, field in self.fields.items():
        field.widget.attrs.update({'class': 'form-control'}) 

Метод get_message извлекает данные из только что заполненной формы, а send отсылает информацию (включая файл резюме) на нужный email (подробнее о настройках почтового сервера ниже):

def get_message(self):
    subject = f'Добавлена анкета {self.instance.name}' 
    msg = str(self.instance)
    return subject, msg

def send(self):
    subject, msg = self.get_message()

    email = EmailMessage(
    subject=subject,
    body=msg,
    from_email=settings.EMAIL_HOST_USER,
    to=[settings.RECIPIENT_ADDRESS],
    )
    email.attach_file(self.instance.resume.path)

    email.send()

Представления

За валидацию формы, сохранение данных в БД и вызов метода отправки информации на email отвечает представление ApplicationView:

class ApplicationView(FormView):
    form_class = ApplicationForm
    template_name = 'application.html'
    success_url = reverse_lazy('success')

    def form_valid(self, form):
        application = form.save()
        form.send()
        return super().form_valid(form)

    def form_invalid(self, form):
        for field, errors in form.errors.items():
            for error in errors:
                messages.error(self.request, f'В поле {field} возникла ошибка: {error}')
        return super().form_invalid(form)

Функция refresh_captcha (совместно со скриптом captcha.js) отвечает за обновление капчи:

def refresh_captcha(request):
    if not request.headers.get('X-Requested-With') == 'XMLHttpRequest':
        raise Http404

    new_key = CaptchaStore.pick()
    to_json_response = {
        "key": new_key,
        "image_url": captcha_image_url(new_key),
    }
    return JsonResponse(to_json_response)

Представление SuccessView выводит сообщение об успешном сохранении данных:

Представление SuccessView выводит сообщение об успешном сохранении данных

Как сделать капчу в Django-приложении

Самые популярные сервисы обработки капчи – Google reCaptcha и hCaptcha. В 2022 году появился сервис Yandex SmartCaptcha. У капчи Яндекса только одно очевидное преимущество – она гарантированно будет работать на российских сайтах. Но, по сравнению с reCaptcha и hCaptcha, настройки SmartCaptcha гораздо сложнее, а пользователей обязательно нужно предупреждать об ее использовании на сайте.

Лучшая альтернатива капче, которая сохраняет и куда-то передает пользовательские данные – автономная капча. Такая капча устанавливается прямо в Django-приложение и не занимается сбором данных – просто предлагает пользователю ввести код с картинки. Самая удобная и гибкая капча для Django django-simple-captcha.

Установка и кастомизация django-simple-captcha

Модуль устанавливается с помощью команды:

pip install django-simple-captcha
Важно!
После установки django-simple-captcha нужно выполнить миграцию БД: python manage.py migrate, а затем добавить маршрут path('captcha/', include('captcha.urls')) в config/urls.py.

Внешний вид и тип задач капчи можно менять как угодно. Все настройки капчи делаются в settings.py. По умолчанию капча использует параметр CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.random_char_challenge' и показывает случайные символы:

По умолчанию капча показывает случайные символы

При желании можно загружать случайные слова из словаря: CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.word_challenge'

Или предлагать простые задачки с помощью CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.math_challenge' :

В django-simple-captcha настраивается абсолютно все: количество и цвет символов, интенсивность шума и степень искажения. Вместо текстовой капчи можно выбрать аудио. В нашем проекте используются случайные русскоязычные слова из заранее определенного списка. Необходимые настройки в settings.py выглядят так:

from config.utils import random_word # импорт кастомного генератора из config/utils.py
CAPTCHA_FONT_PATH = 'fonts/arial.ttf' # кириллический шрифт
CAPTCHA_CHALLENGE_FUNCT = random_word # определение кастомного генератора

Обновление капчи без перезагрузки страницы

Капча получается забористой – хотя в ней используются только русскоязычные слова, иногда их реально сложно прочитать. Настройки читаемости улучшать не следует – тогда капча не будет представлять никакой сложности для ботов. Но для реальных пользователей можно и нужно предусмотреть возможность обновления капчи без перезагрузки формы. За эту функциональность, помимо уже упомянутого представления refresh_captcha, отвечает скрипт captcha.js, подключаемый в шаблоне base.html, маршрут path('captcha/refresh/', refresh_captcha, name='refresh_captcha') и кнопка с id="refresh-captcha".

Как отправить email из Django-приложения

Для отправки имейлов из Django надо либо установить и настроить собственный почтовый сервер (это довольно сложно), либо воспользоваться любым сторонним сервисом, который предоставляет доступ к SMTP для приложений. Такой доступ предоставляет, например, Яндекс.

Как получить пароль для приложения

Учетные данные обычного пользовательского аккаунта нельзя использовать для отправки имейлов из приложений. Чтобы подключить свое приложение к почтовому серверу, надо получить специальный пароль.

Нажмите на шестеренку и выберите Все настройки:

Настройка почты: все настройки

В настройках нажмите на Почтовые программы, отметьте IMAP:

Настройка почты: почтовые программы, IMAP

Перейдите в Пароли приложений, затем в Безопасность:

Настройки почты: пароли приложений

И в разделе Пароли приложений создайте пароль для почты:

Настройка почты

Теперь можно сделать все нужные настройки в settings.py:

  • RECIPIENT_ADDRESS = 'admin@gmail.com' – это адрес, на который будут приходить сообщения. Адресов можно указать несколько, в виде списка.
  • EMAIL_HOST = 'smtp.yandex.ru'
  • EMAIL_PORT = 465
  • EMAIL_USE_SSL = True
  • DEFAULT_FROM_EMAIL = 'sender@yandex.ru' – адрес, который по умолчанию будет указан в поле "от кого".
  • EMAIL_HOST_USER = 'yandex_user@yandex.ru' – адрес, в учетной записи которого вы создали пароль для приложения.
  • EMAIL_HOST_PASSWORD = 'yourownpasswordhere' – тот самый пароль приложения.
  • EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

Эти настройки используются модулем EmailMessage и методом send в форме ApplicationForm. Письма будут приходить с вложенными резюме:

Пример письма с вложенными резюме

Шаблоны и рендеринг формы

Приложение использует 3 шаблона:

Кастомный рендеринг формы происходит application.html. Для вывода чекбоксов используется цикл и счетчик forloop.counter, который подсчитывает итерации:

{% for checkbox in form.certificates %}
<div class="form-check">
   <label for="id_tags_{{ forloop.counter }}">
   <input type="checkbox"
      name="certificates"
      value="{{ forloop.counter }}"
      class="form-check-input"
      id="id_certificates_choice{{ forloop.counter }}"
      value="{{ choice.id }}"/>
   {{ checkbox.choice_label }}
   </label>
</div>
{% endfor %}

При выводе выпадающего списка используются оба значения из кортежа:

<select name="position" class="form-select">
   {% for value in form.position.field.choices %}
   <option value="{{ value.0 }}">{{ value.1 }}</option>
   {% endfor %}
</select>

При визуализации радиокнопок первое значение из кортежа используется в качестве value, второе в качестве label:

{% for value, label in form.commercial_experience.field.choices %}
<div>
   <input type="radio" name="commercial_experience" value="{{ value }}">
   <label>{{ label }}</label>
</div>
{% endfor %}

Подведем итоги

В ходе разработки этого проекта мы научились:

  • Кастомизировать и обновлять капчу без перезагрузки страницы.
  • Рендерить сложные виджеты с помощью пользовательских HTML/CSS стилей.
  • Отправлять данные из Django-формы на имейл – в реальном приложении так можно реализовать подписку на вакансии, новости и т.п.

В следующей статье будем подробно разбирать работу с шаблонами. Весь код для этого проекта – в репозитории.

***

Содержание курса

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
28 июня 2018

3 самых важных сферы применения Python: возможности языка

Существует множество областей применения Python, но в некоторых он особенно...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

Пошаговая инструкция для всех, кто хочет изучить программирование на Python...