Многостраничные формы в Django

Введение

Большинство онлайн-форм помещаются на одной странице. Подумайте о форме «присоединиться к нашему форуму» или «свяжитесь с нами», в которую пользователь вводит имя, адрес электронной почты и, возможно, несколько других элементов информации. Если вы встраиваете такую функциональность в сайт Django, вы можете воспользоваться встроенными в Django классами форм. Это особенно удобно при работе с формами модели, где поля формы соответствуют полям модели, которые будут записаны в вашей базе данных.

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

Итак, давайте сделаем это. В посте ниже мы рассмотрим пошаговую процедуру создания многостраничной формы заявки на работу. Мы начнем с самой простой функциональности, а затем сделаем ее (немного) более сложной. Наиболее важные модули ("models.py", "forms.py" и "views.py") будут воспроизведены здесь, но рабочий, автономный проект доступен на GitHub.

Не стесняйтесь клонировать репозиторий, запустить демо-версию и использовать или модифицировать ее для своих собственных целей.

Отказ от ответственности: нижеприведенный подход я лично использовал для создания многостраничных форм на нескольких разных веб-сайтах. Я уверен, что есть и другие подходы, но это мой личный взгляд на проблему.

Начнем!

Требования

  • Python 3
  • Django 2.2
  • Некоторые базовые знания о том, как разрабатываются сайты на Django

Модель

Мы будем работать с формой модели, поэтому первое, что нам нужно, это модель, которая будет представлять поданную заявку на работу. Следует помнить одну вещь: распределение формы на несколько страниц означает, что экземпляры модели должны быть сохранены в базе данных, прежде чем все будет завершено. В конце концов, поля на странице 2 еще не будут иметь значений при отправке страницы 1. Поэтому некоторые поля в вашей модели должны быть определены с null = True и/или blank = True, даже если вы обычно этого не хотите. Не беспокойтесь - мы все равно сможем потребовать, чтобы пользователь отправил непустое значение, но мы будем делать это на уровне формы, а не на уровне модели (базы данных).

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

models.py

import hashlib, random, sys
from django.db import models
from . import constants
 
def create_session_hash():
  hash = hashlib.sha1()
  hash.update(str(random.randint(0,sys.maxsize)).encode('utf-8'))
  return hash.hexdigest()
 
class JobApplication(models.Model):
  # operational
  session_hash = models.CharField(max_length=40, unique=True)
  stage = models.CharField(max_length=10, default=constants.STAGE_1)
  # stage 1 fields
  first_name = models.CharField(max_length=20, blank=True)
  last_name = models.CharField(max_length=20, blank=True)
  # stage 2 fields
 
  prior_experience = models.TextField(blank=True)
  # stage 3 fields
  all_is_accurate = models.BooleanField(default=False)
 
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    if not self.session_hash:
      while True:
        session_hash = create_session_hash()
        if JobApplication.objects.filter(session_hash=session_hash).count() == 0:
          self.session_hash = session_hash
          break
 
  @staticmethod
  def get_fields_by_stage(stage):
    fields = ['stage']  # Must always be present
    if stage == constants.STAGE_1:
      fields.extend(['first_name', 'last_name'])
    elif stage == constants.STAGE_2:
      fields.extend(['prior_experience'])
    elif stage == constants.STAGE_3:
      fields.extend(['all_is_accurate'])
    return fields

(Обратите внимание, что этот модуль относится к модулю под названием «constants», который определяет значения для «stage» и используется как здесь, так и в «views.py». Этот модуль не воспроизводится в этом сообщении в блоге, но если вы загрузите полный проект от Github, вы найдете его там.)

Одним из полей, необходимых для этой многостраничной формы, является stage, которая позволяет нам определить, какое подмножество полей отображать на странице 1 формы, какое на странице 2 и так далее. Затем поля данных (first_name, last_name, prior_experience и all_is_accurate) будут обрабатывать значения, представленные пользователем.

Но давайте поговорим о поле session_hash. Ключом к созданию многостраничной формы является то, что при отправке данных со страницы 2 (например) вы сохраняете их в той же модели, которую использовали для страницы 1. Поскольку html по своей природе не имеет состояния, мы храним хэш-код в сеанс, чтобы связать отдельные запросы GET и POST вместе. Каждый экземпляр модели получает свой собственный уникальный хэш SHA-1, который будет сохранен в модели при первом действительном запросе POST. Более поздние запросы от браузера пользователя будут включать этот хеш, что позволит нам получить правильный экземпляр модели.

Методы create_session_hash() и __init __() в модели поддерживают это. Обратите внимание на использование цикла while для защиты от исчезающей крошечной вероятности того, что мы случайным образом сгенерируем хеш, который уже существует в модели. Поскольку существует 2^160 различных 40-символьных шестнадцатеричных хеш-кодов, мы не будем долго зацикливаться на этом цикле (и почти наверняка не на всех).

Наконец, нам нужно что-то, чтобы разделить поля модели на группы, которые будут отображаться на странице 1, странице 2 и странице 3 формы. Метод get_fields_by_stage() делает это для нас. Этот метод не требует экземпляра JobApplication для работы, поэтому я сделал его статическим методом.

Форма

Поля типичной формы Django жестко закодированы. Может выглядеть так:

class MyForm(ModelForm):
 
  foo = forms.IntegerField()
 
  class Meta:
    model = MyModel
    fields = "__all__" 

Но многостраничная форма - это динамическая форма. Поля должны быть определены во время выполнения, в зависимости от состояния, в котором находится конкретный экземпляр. Вот самый минимальный "forms.py" для нашего проекта:

forms.py

from django.forms.models import ModelForm
 
class BaseApplicationForm(ModelForm):
  pass

Обратите внимание, что наш класс BaseApplicationForm не имеет жестко закодированных полей, как в типичном примере. На самом деле у него нет ничего, кроме того, что он наследует от ModelForm. Позже мы добавим больше, но это все, что нам нужно для начала.

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

Вот последний большой кусок этого проекта: "views.py":

views.py

from django.forms import modelform_factory
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import FormView
from . import constants
from .forms import BaseApplicationForm
from .models import JobApplication
 
def get_job_application_from_hash(session_hash):
  # Find and return a not-yet-completed JobApplication with a matching
  # session_hash, or None if no such object exists.
  return JobApplication.objects.filter(
    session_hash=session_hash,
  ).exclude(
    stage=constants.COMPLETE
  ).first()
 
class JobApplicationView(FormView):
  template_name = 'job_application/job_application.html'
  job_application = None
  form_class = None
 
  def dispatch(self, request, *args, **kwargs):
    session_hash = request.session.get("session_hash", None)
    # Get the job application for this session. It could be None.
    self.job_application = get_job_application_from_hash(session_hash)
    # Attach the request to "self" so "form_valid()" can access it below.
    self.request = request
    return super().dispatch(request, *args, **kwargs)
 
  def form_valid(self, form):
    # This data is valid, so set this form's session hash in the session.
    self.request.session["session_hash"] = form.instance.session_hash
    current_stage = form.cleaned_data.get("stage")
    # Get the next stage after this one.
    new_stage = constants.STAGE_ORDER[constants.STAGE_ORDER.index(current_stage)+1]
    form.instance.stage = new_stage
    form.save()  # This will save the underlying instance.
    if new_stage == constants.COMPLETE:
      return redirect(reverse("job_application:thank_you"))
    # else
    return redirect(reverse("job_application:job_application"))
 
  def get_form_class(self):
    # If we found a job application that matches the session hash, look at
    # its "stage" attribute to decide which stage of the application we're
    # on. Otherwise assume we're on stage 1.
    stage = self.job_application.stage if self.job_application else constants.STAGE_1
    # Get the form fields appropriate to that stage.
    fields = JobApplication.get_fields_by_stage(stage)
    # Use those fields to dynamically create a form with "modelform_factory"
    return modelform_factory(JobApplication, BaseApplicationForm, fields)
 
  def get_form_kwargs(self):
    # Make sure Django uses the same JobApplication instance we've already
    # been working on.
    kwargs = super().get_form_kwargs()
    kwargs["instance"] = self.job_application
    return kwargs

(Обратите внимание, что этот модуль ссылается на шаблон «job_application.html» и второе представление, называемое «thank_you». Его использование метода reverse() также подразумевает существование «urls.py». Эти элементы не воспроизводятся в этом в блоге, но если вы загрузите полный проект с Github, вы найдете их там.)

Подробная информация о различных методах этого представления в комментариях выше, но вкратце:

  • Метод dispatch() пытается найти существующий экземпляр JobApplication, чье поле session_hash соответствует текущему сеансу пользователя.
  • Выполнение метода form_valid() означает, что все поля получили допустимые значения, и мы можем перейти к следующему этапу, который может быть другой частью формы или страницей «Спасибо».
  • Метод get_form_class() вернет класс формы с полями, соответствующими стадии текущего приложения.
  • Метод get_form_kwargs() является критическим, потому что, если мы не указываем «экземпляр», поведение Django по умолчанию заключается в создании нового объекта Form для каждого запроса.

Обязательные поля и проверка

На данный момент наша многостраничная форма работает, но в ней все еще есть некоторые странные вещи. С одной стороны, мы вообще не проверяем ввод пользователя. У нас также нет возможности сделать поле обязательным. Как ни странно, поле stage может быть изменено пользователем при отправке формы!

Итак, давайте рассмотрим эти проблемы сейчас:

Проверка ввода

Эта часть ничем не отличается от любой формы Django. Вы можете добавить методы clean_<fieldname>() для полей, которые вы хотите проверить, или обычный метод clean() как обычно. Например, мы могли бы изменить наш класс «BaseApplicationForm», который в настоящее время имеет только инструкцию pass, чтобы он выглядел следующим образом:

forms.py

class BaseApplicationForm(ModelForm):
 
  def clean_first_name(self):
    first_name = self.cleaned_data.get("first_name", "")
    if "e" in first_name:
      raise ValidationError("People with 'e' in their first name need not apply.")
    # else
    return first_name 

Обязательные поля и скрытые поля

Нам нужно, чтобы поле stage было скрытым полем, но, поскольку stage - это CharField в модели JobApplication, Django по умолчанию использует TextInput для соответствующего поля в форме. Предположим также, что мы хотим сделать обязательными поля first_name, last_name и all_is_accurate. Нам нужен способ сообщить нашей динамической форме, что определенные поля должны быть обязательными и что другие поля должны отображаться как скрытые входные данные.

Для начала давайте добавим пару новых строк в нашу модель. Возможно, поместите их прямо над определением метода __init __():

models.py

...
 
  hidden_fields = ['stage']
  required_fields = ['first_name', 'last_name', 'all_is_accurate']
 
  def __init__(self, *args, **kwargs):
... 

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

И снова мы изменим класс BaseApplicationForm в "forms.py". Добавьте метод __init __(), чтобы он выглядел так:

forms.py

class BaseApplicationForm(ModelForm):
 
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    required_fields = self.instance.required_fields
    hidden_fields = self.instance.hidden_fields
    for field in self.fields:
      if field in required_fields:
        self.fields.get(field).required = True
      if field in hidden_fields:
        self.fields.get(field).widget = HiddenInput()
 
  def clean_first_name(self):
    first_name = self.cleaned_data.get("first_name", "")
    if "e" in first_name:
      raise ValidationError("People with 'e' in their first name need not apply.")
    # else
    return first_name

Теперь поле stage должно быть скрыто, а поля first_name, last_name и all_is_accurate обязательны для заполнения на уровне формы. Теперь у нас есть рабочая многостраничная форма.

Заключение

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

Опять же, для рабочего примера, вы можете клонировать кодовую базу из GitHub. Рабочий пример содержит все отсутствующие модули и шаблоны, упомянутые выше, а также позволяет просматривать отправленные объекты JobApplication с помощью встроенной админки Django.

Конечно, есть много способов, которыми наше небольшое приложение для работы может быть улучшено для использования на рабочем сайте. Например, мы могли бы добавить created и modified поля в модель JobApplication, что позволит нам принудительно удлаить экземпляры JobApplication, которые слишком долго оставались в неполном состоянии.

models.py


...
  created = models.DateTimeField(auto_now_add=True)
  modified = models.DateTimeField(auto_now=True)
... 

(Рабочий пример в GitHub также реализует эту функцию).

Мы также могли бы добавить кнопку «назад» в форме, чтобы позволить пользователю вернуться к уже отправленным страницам, и кнопку «вперед», чтобы они могли быстро вернуться туда, где они были до того, как использовали кнопку «назад». Возможно, некоторые поля должны быть обязательными, только если другим полям даны значения. И, возможно, продвижение пользователя от страницы 1 формы до конца не должно быть одинаковым для всех пользователей. Мы могли бы реализовать страницы формы, которые отображаются только в том случае, если пользователь ввел определенные значения ранее.

Возможно, эти темы могут стать предметом последующего сообщения. До тех пор, счастливого программирования!

Вернуться на верх