Vues génériques et compléments sur les migrations avec Django

Nous allons à présent explorer les vues génériques de Django qui sont aussi appelées Vues fondées sur des classes. Vous trouverez leur documentation sur le site de django. L’usage des vues génériques permet d’utiliser des vues associées à des classes en lieu et place des fonctions. Cela permet aussi de mieux organiser le code et de davantage le réutiliser en faisant parfois usage de mixins (héritage multiple) Nous approfondirons au passage notre compréhension des migrations Django.

Initialisation du projet

Vous allez créer un petit projet de gestion de musiques pour illuster tout ça !

django-admin startproject GestionMusiques
cd GestionMusiques
./manage.py startapp musiques

N’oubliez pas d’ajouter l’application musiques nouvellement créée dans la liste des apps installées, ainsi que de modifier le fichier urls.py :

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('musiques/', include('musiques.urls'))
]

Création d’un modèle simple

On veut pouvoir stocker des entités modélisant des morceaux de musique. On va donc définir notre modèle dans le fichier models.py de l’app musiques. Le modèle Morceau ne requiert que des champs de type texte, rendez vous sur la documentation officielle pour de plus amples détails sur ce qu’il est possible de faire.

class Morceau(models.Model):
    titre = models.CharField(max_lenght=64)
    artiste = models.CharField(max_length=64)

    def __str__(self):
        return '{self.titre} ({self.artiste})'.format(self=self)

Mettez à jour le schéma de votre base de donnée avec le couple makemigrations/migrate. On reviendra sur ces deux commandes un peu plus tard.

./manage.py makemigrations
./manage.py migrate

Enfin, peuplez votre base de données avec quelques morceaux de bon goût (à partir de la console Django lancée avec python manage.py shell)

from musiques.models import Morceau

Morceau.objects.create(titre='Fruit de la passion', artiste='Francky Vincent')
Morceau.objects.create(titre='Space Oddity', artiste='David Bowie')

Afficher un morceau

Ajout d’un test

Ecrivons maintenant des tests afin de nous assurer que certaines routes existent dans notre app pour afficher les objets de la base de données. Aucune vue Django n’a encore été créée, il est donc évident que les tests vont échouer : c’est tout le principe du Test Driven Development (TDD). Les tests pour une fonctionnalité doivent être écrits avant l’implémentation de la fonctionnalité.

Plus pécisément, nous allons tester d’une part le fait qu’une url nommée existe pour afficher un objet, d’autre part que le traitement de la requête s’effectue sans erreur (code http 200).

from django.urls import reverse
from django.test import TestCase
from django.urls.exceptions import NoReverseMatch

from musiques.models import Morceau


class MorceauTestCase(TestCase):
    def setUp(self):
        Morceau.objects.create(titre='musique1', artiste='artise1')
        Morceau.objects.create(titre='musique2', artiste='artise2')
        Morceau.objects.create(titre='musique3', artiste='artise3')

    def test_morceau_url_name(self):
        try:
            url = reverse('musiques:morceau-detail', args=[1])
        except NoReverseMatch:
            assert False

    def test_morceau_url(self):
        morceau = Morceau.objects.get(titre='musique1')
        url = reverse('musiques:morceau-detail', args=[morceau.pk])
        response = self.client.get(url)
        assert response.status_code == 200

La commande ./manage.py test devrait vous indiquer à ce stade que vos deux tests ne passent pas.

Création d’une url

La première étape consiste à définir une nouvelle url dans notre app :

Direction le fichier musiques/urls.py :

from django.urls import path
from .views import morceau_detail

app_name = 'musiques' # Encapsule les urls de ce module dans le namespace musique
urlpatterns = [
    path('<int:pk>', morceau_detail, name='morceau-detail')
]

La vue morceau_detail n’existe pas, vous allez donc en créer une qui ne fait rien !

def morceau_detail(request, pk):
    pass

La commande ./manage.py test devrait à présent vous informer qu’un test ne passe pas (au lieu des 2).

Et vous pouvez compléter :

from django.shortcuts import render
from django.http import HttpResponse

def morceau_detail(request,pk):
        return HttpResponse('OK')

… pour passer enfin les 2 tests !!

Création de la vue

Vous allez implémenter la vue qui permettra d’afficher une instance du modèle Morceau. Pour cela, vous allez utiliser une vue générique basée sur une classe.

Django fournit différentes classes génériques pour représenter des vues, comme DetailView ou ListView.

C’est la classe DetailView qui va vous intéresser ici puisqu’elle permet d’associer une vue à un model. D’autre classes existent pour répondre à d’autres problématiques, vous en trouverez la liste sur la documentation officielle.

from django.views.generic import DetailView

from .models import Morceau


class MorceauDetailView(DetailView):
    model = Morceau

La vue Django est créée, à présent il faut déterminer comment l’afficher. C’est le template qui va s’en charger. Par défaut, DetailView va chercher un template nommé selon le nom du model renseigné. Ici ce template s’appelle morceau_detail.html et doit être situé dans le dossier templates/musiques de l’app musiques. Créez à présent ce fichier et remplissez le. L’objet attendu s’appelle object ou morceau

La dernière étape consiste à modifier le fichier musiques/urls.py. La méthode morceau_detail n’existe plus, il faut alors indiquer que c’est la classe MorceauDetailView qui va se charger de la requête.

from .views import MorceauDetailView

app_name = 'musiques'
urlpatterns = [
      path('<int:pk>', MorceauDetailView.as_view(), name='morceau-detail')
]

Notez la différence, la fonction path attend une fonction en deuxième paramètre, MorceauDetailView étant une classe, on doit utiliser la méthode de classe as_view pour que path fonctionne correctement.

A présent, la commande ./manage.py test devrait indiquer que tous les tests passent.

Complétons le template morceau_detail.html :

<ul>
<li>{{object.titre}}</li>
<li>{{object.artiste}}</li>
</ul>
<a href="{% url 'musiques:morceau_list' %}">
    Revenir a la liste
</a>

Notez l’usage de {% url 'musiques:morceau_list' %} pour faire référence à la route « morceau_list » que nous allons ajouter à présent à notre app musiques :

ListView

Ajoutez à vos views :

from django.views.generic import ListView

class MorceauList(ListView):
    model = Morceau

Puis à urls.py :

from django.urls import path

from .views import MorceauDetailView, MorceauList
app_name = 'musiques'

urlpatterns = [
    path('<int:pk>', MorceauDetailView.as_view(), name='morceau_detail'),
    path('', MorceauList.as_view(), name='morceau_list'),
]

Avec le petit template correspondant morceau_list.html à placer dans musiques/templates/musiques :

<h2>Morceaux</h2>
<ul>
    {% for morceau in morceau_list %}
    <li>
        <h3>Morceau n°{{morceau.pk}}</h3>
        {{ morceau.titre }} par {{ morceau.artiste }}
    </li>
    {% endfor %}
</ul>

Testez sur http://localhost:8000/musiques !

Complexification du modèle

Le modèle Morceau est très pauvre, beaucoup d’informations manquent. Par exemple, on ne connait pas la date de sortie d’un morceau donné. Heureusement, grâce au système de migrations, le schéma de notre base de données n’est pas figé, on va pouvoir le modifier.

Ajout d’un champ nullable

On va dans un premier temps compléter notre modèle en lui ajoutant un champ date_sortie. Modifiez votre fichier musiques/models.py en ajoutant ce champ date :

date_sortie = models.DateField(null=True)

Il est nécessaire de spécifier null=True car lorsque le moteur de migration ajoutera le champ dans la table, il ne saura pas quelle valeur attribuer à la colonne nouvellement créée. Il mettra donc une valeur nulle par défaut. Il est également possible de spécifier blank=True. Qu’est-ce que cela signifie ? Recherchez dans la doc de Django.

Ensuite, la commande ./manage.py makemigrations musiques va scanner le fichier musiques/models.py pour détecter les changements entre la version courante du modèle et la nouvelle (celle avec le champ date). A la suite de cette commande un fichier est créé dans le répertoire musiques/migrations. Celui-ci est un fichier Python indiquant la marche à suivre pour passer de la version 1 du schéma de base de données à la version 2. Pour appliquer ces changements, on utilise la commande ./manage.py migrate qui va lire ce fichier et appliquer les modifications nécessaires. Utilisez ./manage.py sqlmigrate musiques 0002 pour avoir un aperçu du code SQL appliquant la modification.

Déplacement d’un champ

Vous avez certainement déjà entendu parler du problème de la redondance des données dans une base de données relationnelle. Ce problème apparait lorsqu’une même donnée figure plusieurs fois dans la base de donnée sous des formes différentes. Si on enregistre deux morceaux de musique avec le même artiste, l’information contenue dans la colonne artiste devient redondante : on aimerait pouvoir lier un morceau avec un artiste, qui ne serait créé qu’une seule fois.

On va donc créer une nouvelle classe représentant un artiste dans le fichier musiques/models.py

class Artiste(models.Model):
    nom = models.CharField(max_length=64)

On va ensuite générer un fichier de migration avec la commande makemigrations puis appliquer cette migration avec migrate :

./manage.py makemigrations
./manage.py migrate

Cela aura pour conséquence de générer une table Artiste vide.

Vous allez à présent peupler cette nouvelle table à l’aide d’une migration de données, c’est à dire une migration n’impactant pas le schéma de la base, mais uniquement ses données.

Créez une migration vide avec ./manage.py makemigrations --empty musiques, ce qui aura pour effet de créer le fichier:

musiques/migrations/0004_auto_date_num.py

Ouvrez ce fichier et constatez qu’une liste vide est affectée à la variable operations : cela signifie que cette migration, en l’état, ne fait rien. Vous allez indiquez à Django comment il doit migrer les données de votre base, grâce à la méthode migrations.RunPython. Cette méthode doit recevoir une fonction définissant le comportement de la migration. Il est souvent sécurisant de spécifier le paramètre reverse_code qui permettra à Django d’annuler la migration en cas de problème et de revenir au schéma antérieur.

from django.db import migrations

def migrer_artiste(apps, schema):
    # On récupère les modèles
    Morceau = apps.get_model('musiques', 'Morceau')
    Artiste = apps.get_model('musiques', 'Artiste')

    # On récupère les artistes déjà enregsitrés dans la table Morceau
    # Voir la documentation :
    # - https://docs.djangoproject.com/fr/3.1/ref/models/querysets/#all
    # - https://docs.djangoproject.com/fr/3.1/ref/models/querysets/#values
    # - https://docs.djangoproject.com/fr/3.1/ref/models/querysets/#distinct
    artistes_connus = [fields['artiste']
                       for fields in Morceau.objects.all().values('artiste').distinct()]
    # On peuple la table Artiste
    for artiste in artistes_connus:
        Artiste.objects.create(nom=artiste)

def annuler_migrer_artiste(apps, schema):
    Artiste = apps.get_model('musiques', 'Artiste')
    Artiste.objects.all().delete()

class Migration(migrations.Migration):

    dependencies = [
        ('musiques', '0003_artiste'),
    ]

    operations = [
        migrations.RunPython(migrer_artiste,
                             reverse_code=annuler_migrer_artiste)
    ]

Prenez l’habitude de définir la fonction de retour en arrière (reverse_code) quand c’est possible, ça ne coûte rien et si jamais une erreur a été faite lors de la migration, elle pourra être annulée par un retour en arrière.

A présent, appliquez la migration et vérifiez que la table Artiste n’est pas vide. Retournez à un état antérieur avec la commande ./manage.py migrate musiques 0003 et observez, d’une part que ça marche car on a spécifié l’opération de retour en arrière et d’autre part que la table Artiste est vide. Réappliquez les migrations avec ./manage.py migrate musiques .

Export/Import de données

Notre base doit contenir quelques données. Pour les exporter simplement au format json et disposer rapidement d’un petit jeu de données :

./manage.py dumpdata --format=json musiques > initial_musiques_data.json

On pourra ainsi en disposer pour recréer les données avec :

./manage.py loaddata initial_musiques_data.json

Gitpod

Le code correspondant se trouve sur Gitlab

Le projet est disponible, prêt à être utilisé et complété sur gitpod :

Gitpod Ready-to-Code

Travail supplémentaire

Il reste encore plusieurs migrations à faire :

  1. Ajouter un champ nullable de type ForeignKey dans la table Morceau

  2. Effectuer une migration de données pour lier la clef étrangère à la bonne entrée de la table Artiste

  3. Supprimer le champ artiste de la table Morceau et rendre la nouvelle clef étrangère non nullable

A vous d’écrire ces trois migrations !

Complétez aussi votre application en proposant un CRUD des Morceaux en utilisant des Vues basées sur des classes (comme des CreateView ou UpdateView) et avec un beau CSS. Ajoutez les tests correspondants (fonctionnels ou autres).

Code complet

Le code complet se trouve sur Github Il inclut des templates Bootswatch et des icones fournies par fontawesome.

Le projet est disponible, prêt à être utilisé et complété sur gitpod :

Gitpod Ready-to-Code