Django: enregistrer une entité en utilisant plusieurs pages

Le but de cet article est de vous présenter la méthode que j’ai utilisée pour enregistrer une entité en plusieurs étapes, donc en utilisant plusieurs pages. La sauvegarde en base de données s’effectue seulement lors de la dernière étape.

Contexte technique: Django 1.9.5, Python 3.5.1

Cas concret: enregistrement d’un profil utilisateur sur plusieurs pages. Ici nous nous limiterons à deux pages mais il est très facile de l’étendre à n pages. Donc sur la page 1 nous permettrons à l’utilisateur de saisir certains champs. Ensuite sur la page 2 il renseignera les champs restants et l’enregistrement en base de la totalité des éléments (page 1 + page 2) s’effectuera.

Installation de l’application django

  • Initialisation de l’application
pip install django django-countries
django-admin start project user_profile_several_pages
cd user_profile_several_pages
# Notre application de création de profil utilisateur s'appelle ici website
./manage.py startapp website
# Création du super utilisateur pour la partie admin
./manage.py createsuperuser
  • Éditer le fichier user_profile_several_pages/settings.py et ajouter l’application:
INSTALLED_APPS = [
    ...
    'website',
]
  • Éditer le fichier user_profile_several_pages/urls.py et ajouter les urls:
from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include('website.urls', namespace='website')),
]
  • Depuis la racine du projet Django, créer ensuite le répertoire des templates: website/templates/website

Le modèle (fichier website/models.py)

from django.db import models
from django.contrib.auth.models import User as DjangoUser
from django_countries.fields import CountryField

class UserProfile(models.Model):
    user = models.OneToOneField(DjangoUser, on_delete=models.CASCADE)
    sex = models.CharField(
        u"Sexe", max_length=1, choices=(('m', u'Masculin'), ('f', u'Féminin'))
    )
    city = models.CharField(u"Ville", max_length=20)
    country = CountryField(verbose_name=u"Pays", default="FR")
    web_site = models.URLField(u"Site web", blank=True, null=True)
    age = models.PositiveSmallIntegerField(u"Age", null=True, blank=True)
    information = models.TextField(u"Autres informations", null=True, blank=True)

    class Meta:
        verbose_name = u"Profil utilisateur"
        verbose_name_plural = u"Profils utilisateur"

    def __str__(self):
        return "%s %s" % (self.user.first_name, self.user.last_name)

Rien de bien compliqué dans ce modèle. On notera simplement la référence vers django.contrib.auth.models.User pour les éléments spécifiques au compte utilisateur « système » (first_name, last_name, email, password).

Les forms (fichier website/forms.py)

from django import forms
from django.utils.translation import ugettext, ugettext_lazy as _
from website.models import DjangoUser, UserProfile


class DjangoUserForm(forms.ModelForm):
    error_messages = {
        'password_mismatch': _("The two password fields didn't match."),
    }
    first_name = forms.CharField(required=True, label=u'Nom')
    last_name = forms.CharField(required=True, label=u'Prénom')
    email = forms.EmailField(required=True, label=u'Mail')
    password = forms.CharField(required=True, label=u'Password', widget=forms.PasswordInput)
    password_conf = forms.CharField(required=True, label=u'Password (confirmation)', widget=forms.PasswordInput)

    def clean_password_conf(self):
        """
        Cette méthode est exécutée automatiquement
        lorsque form.is_valid() est invoquée dans la vue
        """
        password = self.cleaned_data.get('password')
        password_conf = self.cleaned_data.get('password_conf')
        if password and password_conf:
            if password != password_conf:
                raise forms.ValidationError(
                    self.error_messages['password_mismatch'],
                    code='password_mismatch',
                )
        return password_conf

    class Meta:
        model = DjangoUser
        fields = ['first_name', 'last_name', 'email',]

class UserProfileFormStep1(forms.ModelForm):
    class Meta:
        model = UserProfile
        exclude = ['user', 'web_site', 'age', 'information']

class UserProfileFormStep2(forms.ModelForm):
    class Meta:
        model = UserProfile
        fields = ['web_site', 'age', 'information']

On peut noter ici la présence de trois formulaires:

  • DjangoUserForm: va être utilisé pour afficher dans la page les champs qui nous intéressent pour la gestion du compte Django. A noter la présence de la méthode clean_password_conf, qui va etre automatiquement appelée lorsqu’on va appeler la méthode is_valid() sur le formulaire dans la méthode post de la vue. Ainsi cette méthode de validation commence par clean_ suivi du nom du champ à valider.
  • UserProfileFormStep1: formulaire pour la première page. On exclut donc les champs qui vont être affichés dans la seconde page.
  • UserProfileFormStep2: formulaire pour la seconde page. On inclut donc seulement les champs qui vont être affichés dans la seconde page.

La vue (fichier website/views.py)

On utilise ici une vue générique basique pour bénéficier d’un minimum de traitements automatiques du framework Django. J’hérite ici de la TemplateView qui est assez rudimentaire. Je n’ai pas utilisé de vue de plus « haut niveau » (type FormView) car j’avais besoin d’effectuer des traitements manuels pour coller à mes besoins.

from django.core.urlresolvers import reverse
from django.db import transaction
from django.views.generic.base import TemplateView
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.contrib import messages
from django.contrib.auth.models import User

from website.forms import *


class ProfileAdd(TemplateView):
    template_name = 'website/profile_edit.html'

    def get(self, request, step, **kwargs):
        context = super(ProfileAdd, self).get_context_data(**kwargs)
        if step == '1':
            context['up_form'] = UserProfileFormStep1(prefix='user_profile')
            context['user_form'] = DjangoUserForm(prefix='user')
        elif step == '2':
            context['up_form'] = UserProfileFormStep2()
        else:
            logger.error("Step number invalid: '%s'" % step)

        context['step'] = step
        return render(
            request,
            self.template_name,
            context,
        )

    def post(self, request, step, **kwargs):
        context = super(ProfileAdd, self).get_context_data(**kwargs)
        context['step'] = step
        if step == '1':
            user_form = DjangoUserForm(request.POST, prefix='user')
            up_form = UserProfileFormStep1(
                request.POST, prefix='user_profile'
            )
            context['user_form'] = user_form
            context['up_form'] = up_form
            if up_form.is_valid() and user_form.is_valid():
                user = User.objects.filter(email=user_form.cleaned_data['email'])
                if user.exists():
                    user_form.add_error('email', u'Ce mail existe déjà.')
                    return render(
                        request,
                        self.template_name,
                        context,
                    )
                request.session['user_form'] = user_form.cleaned_data
                request.session['up_form'] = up_form.cleaned_data
                return HttpResponseRedirect(
                    reverse('website:profile_add', args=[2])  # step=2
                )
            else:
                return render(
                    request,
                    self.template_name,
                    context,
                )
                
        elif step == '2':
            up_form2 = UserProfileFormStep2(
                request.POST
            )
            context['up_form'] = up_form2
            if up_form2.is_valid():
                user_form = DjangoUserForm(
                    request.session['user_form']
                )
                up_form1 = UserProfileFormStep1(request.session['up_form'])
                with transaction.atomic():
                    user = user_form.save(commit=False)
                    # Dans notre système le mail est aussi le nom d'utilisateur
                    user.username = user_form.cleaned_data['email']
                    # Le formulaire contient un mot de passe en clair
                    unencrypter_pass = user.password
                    # Donc on utilise set_password pour le chiffrer
                    # Afin qu'il ne soit pas en clair dans la base de données
                    user.set_password(unencrypter_pass)
                    user.save()
                    user_profile = up_form1.save(commit=False)
                    user_profile.user = user
                    user_profile.web_site = up_form2.cleaned_data['web_site']
                    user_profile.age = up_form2.cleaned_data['age']
                    user_profile.information = up_form2.cleaned_data['information']
                    user_profile.save()

                return HttpResponseRedirect(
                    reverse('website:user_profile_created')
                )

        return render(
            request,
            self.template_name,
            context,
        )

La méthode get

Elle est appelée lorsqu’on accède à la première ou seconde page. Le paramètre step est passé en paramètre lorsqu’on appelle cette vue (voir plus loin la configuration des urls). Cette méthode effectue essentiellement deux choses:

  • Initialisation des forms à passer dans le template via le contexte.
  • Passer dans le contexte le paramètre step afin de savoir dans le template à quelle étape nous nous trouvons.

La méthode post

Elle est évidemment un peu plus compliquée et comme son nom l’indique récupère les données saisies dans la première page et dans la seconde page suite à l’envoi du formulaire.

  • Cas de la première page (step == 1)
    • On récupère les données de la requête et on les met dans les deux formulaires user_form et up_form. On les insère dans le contexte ce qui permettra de réafficher les données saisies en cas d’erreur.
    • On vérifie la validité des formulaires en faisant appel à la méthod is_valid() ce qui implique l’appel automatique des méthodes de validation du formulaire (comme clean_password_conf de DjangoUserForm).
    • On vérifie que l’email n’existe pas déjà.
    • Les données sont ensuite stockées en session pour pouvoir les récupérer lorsqu’on voudra enregistrer l’ensemble des données après l’envoi du formulaire correspondant à la page 2.
    • Si tout est bon, on appelle la vue mais avec le paramètre step=2.
  • Cas de la seconde page (step == 2)
    • On crée le formulaire en récupérant les données saisies dans la seconde page.
    • Si les données sont valides, on restaure les formulaires de la page 1, c’est à dire le formulaire utilisateur système (user_form) et le formulaire profile utilisateur partie 1 (up_form).
    • Ensuite on initie une transaction car on va sauvegarder en bases deux entités distinctes: celle de l’utilisateur système (django.contrib.auth.models.User) et celle du profile d’utilisateur (website.models.UserProfile). Ainsi si l’un des deux enregistrements échoue, on ne souhaite pas que l’autre soit sauvegardé en base.
    • Ensuite on récupère une instance de User suite à sa sauvegarde. Cependant on n’effectue pas de commit immédiatement afin de pouvoir chiffrer le mot de passe saisi.
    • En ce qui concerne le UserProfile on utilise la même technique d’enregistrement différé pour pouvoir affecter à l’instance UserProfile une instance User créée au point précédent.

Les urls (fichier website/urls.py)

from django.conf.urls import url
from django.views.generic import TemplateView

from website import views

urlpatterns = [
    url(
        r'^$', TemplateView.as_view(
            template_name='website/index.html'
        ),
        name="home"
    ),
    url(
        r'^profile_add/(?P<step>\\d+)/$',
        views.ProfileAdd.as_view(), name="profile_add"
    ),
    url(
        r'^user_profile_created/$', TemplateView.as_view(
            template_name='website/user_profile_created.html'
        ),
        name="user_profile_created"
    ),
]

On a trois urls qui correspondent aux trois vues qui nous redirigent vers trois templates:

  • Page d’accueil
  • Page de saisie des informations du profil
  • Page de confirmation de la création du profil

Les templates

website/templates/index.html

<html>
    <head>
        <title>Page d'accueil</title>
    </head>
    <body>
        <p><a href="{% url 'website:profile_add' step=1 %}">Créer un nouveau profil utilisateur</a></p>
    </body>
</html>

website/templates/profile_edit.html

Il est commun pour la saisie des données en page 1 et en page 2.

<form id="profile_form" method="post" enctype="multipart/form-data" action="">{% csrf_token %}
  {% if up_form.errors or user_form.errors %}
    <p>Le formulaire contient des erreurs :</p>
  {% endif %}
  <table>
    {% if step == "1" %}
      {{ user_form.as_table }}
      {{ up_form.as_table }}
    {% endif %}
    {% if step == "2" %}
      {{ up_form.as_table }}
    {% endif %}
    <tr>
      <td colspan="2">
        <button 
          type="submit">{% if step == "1" %}Suivant{% elif step == "2" %}Enregistrer{% endif %}>
        </button>
      </td>
    </tr>
  </table>
</form>

A noter qu’à la limite on aurait pu se passer du test sur la variable step pour l’affichage des formulaires. A l’étape 2 user_form n’est pas instancié donc il n’aurait pas été affiché. Mais une des règles de Python est bien « explicite est mieux qu’implicite » non ?

website/templates/user_profile_created.html

<html>
    <head>
        <title>Création utilisateur</title>
    </head>
    <body>
        <p>L'utilisateur a bien été créé</p>
    </body>
</html>

Création du module d’administration (fichier website/admin.py)

Il est simplement utilisé ici pour vérifier que la création du profil utilisateur s’est effectuée correctement.

from django.contrib import admin

from website.models import UserProfile


admin.site.register(UserProfile)

Lancement de l’application

./manage.py makemigrations
./manage.py migrate
./manage.py runserver
  • Dans le navigateur accéder à la page d’accueil (par défaut: localhost:8000).
  • Vérifier dans la partie admin que l’utilisateur a bien été crée (par défaut: localhost:8000/admin).

Remarques sur cet article

  • Dans un contexte réel d’application, on aurait fait une vue pour éditer un profil existant. Mais l’article est déjà long et ce n’est pas son but.
  • Je n’ai pas trop détaillé les commandes Django. Je pars du principe que si on s’intéresse à ce genre d’article c’est qu’on sait utiliser un minimum Django.

Voilà j’espère que cet article a pu vous être utile. Ceci dit en aucun cas je ne prétends que c’est la meilleur façon de faire, que c’est la solution la plus rapide. D’ailleurs si vous avez des remarques je suis preneur, car parfois quand on code et qu’on souhaite à tout prix résoudre un problème, on a du mal à prendre du recul.

Vus : 2043
Publié par Marco : 47