Une checklist des bonnes pratiques pour réussir vos projets Django

Le phase du Grau d'Agde

Django est un excellentissime framework web. Tellement excellentissime que c'est un outil que j'emploie depuis quelques années avec toujours autant de plaisir.

Ces quelques années m'ont amené à considérer certaines façons de faire plutôt que d'autre, et à compiler une liste de bonnes pratiques qui me paraissent positives dans la majorité des cas.

Ces bonnes pratiques ont différents objectifs, qui finissent plus ou moins par se rejoindre :

  • faciliter l'arrivée de nouvelles personnes dans l'équipe de développement ;
  • améliorer la maintenabilité des fonctionnalités existantes et diminuer la fréquence d'apparition de nouveaux bugs ;
  • garantir une vitesse de développement constante dans le temps ;
  • garantir une qualité technique et fonctionnelle constante dans le temps.

Rappelons que les points listés ci dessus ne sont pas des buts en soit, ils ont de la valeur car ils ont pour conséquences que toutes les personnes impliquées dans le projet (équipe de développement, client·e·s, etc.) seront bien plus heureuses de se lever le matin. Et vu que je consacre à ce genre de projet l'essentiel de mes journées, j'aime autant en faire des moments agréables.

Certaines de ces pratiques sont issues de jours de galère à regretter de mauvaise décisions, ou ont été « volées » à des gens plus expérimentés que moi. J'essaye autant que possible de lier la source à chaque fois.

Mon but n'est pas de rappeler certaines évidences (il faut écrire des tests, il faut utiliser une plateforme d'intégration continue, etc.), mais plutôt de faire la promotion de pratiques qui ne me paraissent pas toujours très répandues.

Je vais aussi me cantonner aux pratiques techniques, et laisser les bonnes pratiques de gestion de projets à d'autres qui font ça très bien.

Voici donc la liste des pratiques à mettre en œuvre (si ce n'est pas encore fait) sur votre projet Django.

Tests et qualité

Découper les fichiers de tests

Lorsque vous créez une application, Django génère un fichier tests.py pour vous. Ce fichier va grossir et devenir difficile à maintenir. En pratique il sera plus efficace de le remplacer par un module tests contenant de multiples fichiers contenant les tests des différentes parties de votre application <tests-avances-avec-django>.

  • tests_views.py
  • tests_models.py
  • tests_signals.py
  • tests_forms.py
  • etc.

Source

Ne pas se contenter de tester le code Python

La plupart des applications modernes utilisent Javascript plus ou moins intensivement pour déporter certaines fonctionnalités côté navigateur. Comme le code Python, ce code doit être testé. Oui mais… Oui mais non ! Aucune excuse ! Du code non testé, c'est du code qui n'existe pas !

Si votre application embarque du Javascript non testé, vous méritez d'être privé·e de Nutella jusqu'à récupérer une couverture de test minimale (je sais, c'est dur, mais ça vous apprendra).

Utilisez Chai, Mocha et Sinon pour les tests unitaires de vos modules Javascript. Pour les tests fonctionnels, utilisez Casperjs ou Selenium.

Utiliser Factory boy pour les fixtures

Les tests nécessitent forcément des données. Django embarque un mécanisme de fixtures basé sur des fichiers json ou yaml (qui a dit xml ? et pourquoi pas excel tant qu'on y est ?).

En pratique, dès que votre projet va grossir ou que vous allez refactoriser vos modèles, la maintenance de ces fixtures va devenir un vrai cauchemar. Mieux vaut utiliser une application dédiée comme l'excellent Factory boy, qui permettra à chaque test de créer ses propres données au moment voulu.

Limiter les initial_data au strict minimum

Les fichiers de fixtures nommés initial_data.xxx sont chargés pour remplir la base de donnée après chaque appel à syncdb. Cette fonctionnalité amène plus de problèmes que de solutions, surtout depuis Django 1.7 qui a déprécié syncdb pour systématiser l'utilisation de migrations.

Mieux vaut ne pas utiliser de fichiers initial_data.

Utiliser SQlite pour accéler les tests

Même s'il est bon de jouer les tests dans un environnement qui se rapproche le plus possible de la production, il faut parfois rester pragmatique : mieux vaut des tests qui tournent vite avec SQLite que des tests pas lancés du tout parce qu'il faut attendre 5 minutes l'initialisation d'Oracle (!).

Vos settings de test devraient donc contenir quelque chose comme :

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": ":memory:",
    },
}

Oui, mais si j'utilise des fonctionnalités spécifiques à mon SGBD ? Justement, parlons en…

Rester agnostique quant à la base de données

Même si certaines bases de données (comme Postgres) fournissent des extensions très appréciables, mieux vaut ne les utiliser qu'en dernier ressort et rester agnostique quant au SGBD utilisé.

Dans la situation ou des besoins supplémentaires de stockage / traitement des données (genre NoSQL) surviendraient, mieux vaut compléter la stack technique avec des outils dédiés (Redis, Elasticsearch, etc.)

Edit quelques mois plus tard

J'en suis un peu revenu. PostgreSQL, c'est vraiment bien, et si on peut se faciliter grandement la vie, et qu'est-ce que ça peu faire si notre application peut effectivement tourner sur 15 SGBD différents ?

Organisation du projet

Ne pas mettre le projet Django à la racine

Votre projet est constitué d'un projet Django, mais pas que. Il y aura aussi une doc, des fichiers de configuration, des scripts de livraison, et que sais-je encore.

Par conséquent, le code source de votre projet Django ne doit pas se trouver à la racine de votre dépôt Git, mais dans un sous-répertoire que l'on nommera à l'envie « src », « prj », « web » ou « jaimelescookies », n'importe quoi tant que ça reste cohérent et compréhensible dans le contexte du projet.

Créer une appli dédiée pour la configuration du projet

Jusqu'à peu, lorsqu'on créait un projet, Django laissait les fichiers urls.py, wsgi.py, settings.py, etc. dans le répertoire racine. C'est peut-être encore le cas pour votre projet qui a été initialisé avec une version de Django pas toute récente.

Si c'est le cas, il serait plus sage de créer une application dédiée « core » ou « lenomdevotreprojet » pour y ranger ces fichiers.

Découper les requirements

Différents environnements nécessitent l'installation de dépendances spécifiques. Ainsi il est inutile d'installer Gunicorn en local, et nuisible d'installer la debug-toolbar en production.

Pour les requirements, je préconise un découpage qui ressemble à :

  • requirements.txt
  • requirements/base.txt
  • requirements/dev.txt
  • requirements/test.txt
  • requirements/prod.txt

Le fichier base.txt contient les dépendances communes à tous les environnements (Django, etc.)

dev.txt contient les applications à n'installer que sur l'environnement de développement local (debug-toolbar, sphinx, etc.)

Je vous laisse deviner à quoi servent test.txt (factory-boy, mock, etc.) et prod.txt (gunicorn, psycopg2, raven, etc.)

dev.txt, test.txt et prod.txt référencent base.txt en démarrant par cette ligne:

-r base.txt

Quand au fichier requirements.txt à la racine, sa présence est requise par certains outils (comme Heroku, par exemple, si je ne me fourvoie pas). On se contentera de référencer le fichier adéquat:

-r requirements/production.txt

Encore un truc qui provient, si ma mémoire ne me trahit pas, de l'excellent livre Two Scoops of Django.

Le bon code dans les bons fichiers

Si vous décrivez des formulaires dans des fichiers views.py, ou si vos templates html contiennent du css ou du js, je vous jure que vous allez vous en mordre les doigts à un moment ou à un autre.

Les formulaires vont dans des fichiers forms.py. Le css et le js vont dans des fichiers dédiés. Les vues ne contiennent que le strict minimum pour passer les bonnes données au bon template, et le code métier va dans les modèles ou des bibliothèques dédiées.

Une application par fonctionnalité

Django fait la part belle à la notion d'application, qui permet de regrouper le code par fonctionnalité (tous les modèles, vues, templates, etc. d'une fonctionnalité ensemble, plutôt que tous les modèles du projet d'un côté, toutes les vues d'un autre…).

Dans la plupart des projets sur lesquels je suis intervenu, le découpage était très insuffisant, et on trouvait quatre ou cinq applications là où il en aurait fallu une quinzaine.

Plus votre découpage en applications sera granulaire, plus la maintenance sera facile (tant qu'on reste cohérent, bien sûr). Il n'y a pas de règle absolue, mais si une application contient plus de quatre ou cinq modèles ou cinq ou six vues, il est tant de se poser la question d'un découpage.

Déporter les applications dans leur propre dépôt

Quitte à écrire des applications indépendantes, autant en faire des projets séparés. Plus vous allégez votre projet en développant des applications réutilisables indépendantes, plus facile en sera la maintenance.

Respecter le découpage en applications

Attention à bien respecter le découpage en applications. Par exemple, j'ai vu beaucoup de projets avec une application « api » qui contenait du code transverse au projet. Mieux vaut dans chaque application un sous-répertoire « api » qui contient les fichiers nécessaires (serializers.py, views.py, etc.)

Idem pour les tâches Celery, par exemple. mieux faut un fichier tasks.py dans chaque application qu'une application dédiée tasks qui contient les tâches de l'intégralité du projet.

Configuration

Différentes configurations pour différents environnements

Lorsqu'on créé un projet, Django génère un fichier settings.py. Il est bien évident que différents environnements nécessitent différentes configurations.

Commencez par supprimer ce fichier pour le remplacer par un répertoire settings. Dedans, on trouvera :

  • base.py
  • dev.py
  • test.py
  • prod.py

base.py contient évidemment les paramètres qui sont communs à tous les environnements. Les autres fichiers correspondent aux différents environnements, et commencent par la ligne suivante :

from base import *  # noqa

Correctement configurer l'activation d'extensions spécifiques

Si je devais compter le nombre de projets qui ont des outils de debug activés en production… Si je me permets de répéter que la debug-toolbar ne doit être activée que sur l'environnement de développement local, c'est uniquement parce que ce n'est apparemment pas si évident que ça.

Un fichier spécial pour la config qui ne doit pas être versionnée

On trouve souvent la recommandation d'utiliser les variables d'environnement unix pour gérer la configuration en production. Cette pratique me semble être une aberration (mais c'est un avis partial). J'ai adopté une pratique différente mais qui me semble plus logique, cohérente et facile à maintenir. Plutôt que de déporter de la configuration Django dans un truc qui n'est pas Django, j'ai un fichier de settings production.py qui contient quelque chose comme ça :

from base import *  # noqa

from django.core.exceptions import ImproperlyConfigured

try:
    import prod_private
except ImportError:
    raise ImproperlyConfigured("Create a prod_private.py file in settings")

def get_prod_setting(setting):
    """Get the setting or return exception """
    try:
        return getattr(prod_private, setting)
    except AttributeError:
        error_msg = "The %s setting is missing from prod settings" % setting
        raise ImproperlyConfigured(error_msg)

DATABASES = get_prod_setting('DATABASES')
CACHES = get_prod_setting('CACHES')
SECRET_KEY = get_prod_setting('SECRET_KEY')
EMAIL_HOST = get_prod_setting('EMAIL_HOST')
EMAIL_HOST_PASSWORD = get_prod_setting('EMAIL_HOST_PASSWORD')
# …

Toutes les variables de configuration qui ne doivent pas être versionnées se trouvent dans un fichier prod_private.py (assurez vous de configurer votre fichier .gitignore correctement).

Définir une stratégie de logging

Avoir des logs, c'est bien. Mais savoir qui loggue quoi et où, c'est une autre paire de manches. Il existe différentes façons de récupérer et traiter vos logs : Sentry, Logstash + Elasticsearch, etc. Le tout c'est de le définir à l'avance pour avoir des logs qui servent à quelque chose.

Inutile évidemment de vous dire de bannir les "print" de votre code, n'est-ce pas ?

Convention de développment

Caractères de fins de lignes

@#! ces satanés windowsiens et leurs caractères de fins de lignes fantasques ! Si votre équipe est équipée de systèmes d'exploitation hétéroclites, assurez vous que les configurations soient harmonisées en ce qui concerne les caractères de fin de ligne : lf pour tout le monde (et non pas crlf).

Cette configuration se fait bien évidemment au niveau individuel, dans l'éditeur, et en désespoir de cause au niveau du système de gestion de contenu.

Il faut que je vous parle des caractères utilisés pour l'indentation ? Non hein ?! Bon.

Systématiser l'utilisation d'un validateur syntaxique come flake8

Je peste chaque fois que je mets les doigts dans du code Python qui ne respecte pas la Pep 8. Python est unique en son genre (à ma connaissance) en ce sens qu'il propose une convention de développement à l'échelle du langage même. Pourquoi ne pas tirer parti de cette chance ?! Les validateurs syntaxiques, ce n'est pas pour les chiens, que diable !

Un validateur devrait être systématiquement utilisé :

  1. en tant que plugin dans l'éditeur de chaque membre de l'équipe de développement ;
  2. en tant qu'étape de construction dans votre plateforme d'intégration continue.

Dans le doute, installez Flake8.

Ne pas se contenter de valider le code Python

Pourquoi, au nom de Saint Castor, certaines personnes utilisent une indentation différente pour différents types de fichiers ! Ces gens aiment-ils faire souffrir leurs pairs ? C'est du pur sadisme !

Votre projet ne contient pas que des fichiers Python. Pour le Javascript, vous pouvez utiliser Grunt et Jshint, par exemple.

Proscrire les imports absolus

C'est sans doute une des règles de la Pep 8 les moins respectées dans les projets que je récupère. Si vous utilisez les imports absolus (from xxx import *) dans votre projet Django, vous allez vous manger des erreurs vraiment difficiles à débugger à un moment ou à un autre. Ne le faites pas.

Applications tierces

Installer la Django Debug Toolbar

Si vous ne deviez installer qu'une seule extension sur votre projet, ce serait la Debug Toolbar. Je n'arrive pas à imaginer une seule raison de ne pas activer cette extension (en local uniquement, évidemment).

Installer Django-annoying

Django Annoying contient divers raccourcis et outils destinés à faciliter l'écriture de tâches courantes. On trouve notamment le fameux décorateur render_to. Un exemple sera sans doute plus parlant.

Sans render_to :

from django.shortcuts import render

def my_view(request):
    # View code here...
    return render(request, 'myapp/index.html', {"foo": "bar"},
        content_type="application/xhtml+xml")

Avec render_to :

from annoying.decorators import render_to

@render_to('myapp/index.html')
def my_view(request):
    # View code here...
    return {"foo": "bar"}

Moralité : utilisez render_to.

Installer Django-model-utils

Django Model Utils est une autre extension qui fournit quelques outils forts utiles, notamment la classe Choices. Rebelote, voici un exemple.

Sans Choices :

class Article(models.Model):
    STATUS_DRAFT = 'draft'
    STATUS_PUBLISHED = 'published'
    STATUS = (
        (STATUS_DRAFT, _('draft')),
        (STATUS_PUBLISHED, _('published')),
    )
    status = models.CharField(
        choices=STATUS,
        default=STATUS_DRAFT,
        max_length=20)

    def is_published(self):
        return self.status == STATUS_DRAFT

Avec Choices :

from model_utils import Choices

class Article(models.Model):
    STATUS = Choices(
        ('draft', _('draft')),
        ('published', _('published'))
    )
    status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20)

    def is_published(self):
        return self.status == STATUS.draft

Moralité : Si vous déclarez et utilisez des constantes dans vos modèles, installez model_utils et utilisez Choices.

Utilisez Celery pour les tâches qui peuvent être asynchrones

Lorsqu'une requête de l'utilisateur génère une action quelconque qui peut prendre du temps mais peut être rendue asynchrone, mieux vaut déporter cette action dans une tâche Celery. Quelques exemples de telles tâches :

  • envoyer un email après le remplissage d'un formulaire ;
  • traiter un fichier qui vient d'être uploadé ;
  • télécharger des données depuis un site / api distant ;
  • effectuer l'encoding d'une image / vidéo ;
  • réaliser des calculs intensifs ;
  • etc.

En déportant les traitements lourds dans une tâche Celery, on rend immédiatement la main à l'utilisateur au lieu de le faire poireauter plusieurs looooooongues secondes…

Templates et assets

Réduire le fichier "base.html" à son strict minimum

C'est une bonne pratique d'avoir un template base.html dont hériteront tous les autres templates du projet. En revanche, ce fameux base.html doit contenir le strict minimum pour bootstraper votre code html.

J'ai vu des projets avec un base.html contenant tous les blocs possibles et imaginables, et des sous-templates qui rivalisaient d'ingéniosité pour masquer ou supprimer des blocs.

Le principe de l'héritage, c'est d'avoir des objets qui sont de plus en plus précis et utiles au fur et à mesure de la descente dans la hiérarchie (non, je ne ferai pas de blague sur les hommes politiques), pas l'inverse. Je ne vois pas de raison de ne pas respecter ce principe élémentaire pour l'héritage de templates.

Systématiquement utiliser les balises d'internationalisation

« C'est bon coco, on s'adresse uniquement au public parisien, tu peux mettre le texte direct en français dans les templates. »

6 mois plus tard…

« Ouais coco, en fait, on voudrait se lancer au Canada, il faut donc que tu repasses dans tous les templates pour encadrer tous les textes par des balises d'internationalisation… »

Évitez vous le cauchemar précédent, utilisez systématiquement des balises d'internationalisation pour afficher du texte dans vos templates.

<h1>Mon titre</h1> <!-- Pas glop ! -->

<h1>{{ _('My title') }}</h1> <!-- Glop glop ! -->

Utilisez Django-pipeline pour les assets

La gestion des assets (fichiers css et js) est un truc qui paraît simple mais peut vite devenir compliqué, surtout dès qu'on essaye d'optimiser un peu la concaténation / compression des différents fichiers.

Plutôt que d'inclure directement ces fichiers via des balises link ou script dans vos templates, mieux vaut utiliser une application dédiée, comme l'excellente Django-pipeline.

Exemple.

<!-- base.html -->

{% load compressed %}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        {% compressed_css "base" %}
        {% block extra_css %}{% endblock extra_css %}
    </head>
    <body>
        <div class="container">
            {% block content %}{% endblock content %}
        </div>
        {% compressed_js "base" %}
        {% block extra_js %}{% endblock extra_js %}
    </body>
</html>
<!-- document_detail.html -->

{% extends "base.html" %}
{% load compressed %}

{% block extra_css %}
    {% compressed_css "document_detail" %}
{% endblock extra_css %}

{% block content %}
    …
{% endblock content %}

{% block extra_js %}
    {% compressed_js "detail" %}
{% endblock extra_js %}
# settings.py

PIPELINE_CSS = {
    'base': {
        'source_filenames': (
            'css/vendors/bootstrap.css',
            'css/project.css',
        ),
        'output_filename': 'css/base.css',
    },
    'document_detail': {
        'source_filenames': (
            'css/vendors/selectize.css',
        ),
        'output_filename': 'css/document_detail.css',
    },
}

PIPELINE_JS = {
    'base': {
        'source_filenames': (
            'js/vendors/jquery.js',
            'js/vendors/bootstrap.js',
            'js/notifications.js',
        ),
        'output_filename': 'js/base.js',
    },
    'document_detail': {
        'source_filenames': (
            'js/vendors/selectize.js',
            'js/document-detail.js',
        ),
        'output_filename': 'js/document_detail.js',
    },
}

Ainsi, les assets à utiliser sont définis dans les settings, ce qui permet d'appliquer des stratégies de compression / concaténation différentes en local et en production, par exemple, et la maintenance en est grandement facilitée.

Utiliser la directive include pour découper les templates

Un template de dix-huit mètres
Avec un chapeau sur la tête
Ça n'existe pas, ça n'existe pas.

Les gens qui écrivent des templates longs comme un jour avec des chaussettes dépareillées, c'est quoi votre problème ?

Sans sombrer dans le ridicule, découper un gros template en plusieurs petits, c'est franchement plus facile à maintenir, et ça ne coûte rien, alors faites le.

Pièges plus ou moins courants

Utilisez des timezones pour vos dates par défaut

Dans ses modèles, Django propose la possibilité d'utiliser des champs DateTimeField, qui permettent comme leur nom l'indique de stocker des valeurs temporelles. Ces champs proposent des options auto_now_add et auto_now qui permettent de spécifier automatiquemnt la valeur actuelle au moment de la création / modification de l'objet.

Avant Django 1.7, cette valeur ne tenait pas compte de la configuration concernant les fuseaux horaires. Ainsi, le code suivant :

class Thing(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)

thing = Thing.objects.create()

Provoquait l'affichage suivant

DateTimeField received a naive datetime (2012-06-29 15:02:15.074000) while time zone support is active.

Si vous n'avez pas encore mis à jour vos dépendances, ou développez des extensions qui doivent être compatible avec des versions de Django < 1.7, le code précédent devrait être remplacé par celui-ci :

from django.utils import timezone

class Thing(models.Model):
    created_at = models.DateTimeField(default=timezone.now)

Stocker les valeurs HT et TTC

Les valeurs monétaires, excellent exemple d'une fonctionnalité qui paraît toute simple et s'avère un pur casse-tête en pratique.

Considérons le code suivant :

class Thing(models.Model):
    price_without_vat = models.PositiveIntegerField()

    @property
    def price_with_vat(self):
        return self.price_without_vat * settings.VAT_AMOUNT

Ce code d'apparence anodine, vous le maudirez le jour ou notre cher gouvernement décidera de changer le montant de la TVA ! Ça n'arrive pas souvent, mais ça arrive. On préférera donc écrire :

class Thing(models.Model):
    _price_without_vat = models.PositiveIntegerField(db_column='price_without_vat')
    _price_with_vat = models.PositiveIntegerField(db_column='price_with_vat')

    @property
    def price_without_vat(self):
        return self._price_without_vat

    @price_without_vat.setter
    def price_without_vat(self, price):
        self._price_without_vat = price
        self._price_with_vat = price * settings.VAT_AMOUNT

Cela dit, la gestion des valeurs monétaires et de la TVA nécessiterait un article à part entière.

Grouper les actions communes

Si plusieurs valeurs dans un modèle doivent systématiquement être modifiées en même temps, alors ces modifications doivent être effectuées dans une méthode ou une propriété dédiée.

Exemple.

class Thing(models.Model):
    status = models.CharField(
        choices=STATUS,
        default=STATUS.draft,
        max_length=20)
    status_changed_at = models.DateTimeField()


thing = Thing()
thing.status = STATUS.published
thing.status_changed_at = timezone.now()

Dans le code ci-dessus, on voit que modifier le statut de l'objet nécessite deux affectations. Vous pouvez être certain que tôt ou tard, quelqu'un quelque part oubliera de modifier le deuxième champ. On préférera donc écrire…

class Thing(models.Model):
    status = models.CharField(
        choices=STATUS,
        default=STATUS.draft,
        max_length=20)
    status_changed_at = models.DateTimeField()

    def set_status(self, status):
        self.status = status
        self.status_changed_at = timezone.now()

thing = Thing()
thing.set_status(STATUS.published)

Ou, encore mieux…

class Thing(models.Model):
    _status = models.CharField(
        db_column='status',
        choices=STATUS,
        default=STATUS.draft,
        max_length=20)
    status_changed_at = models.DateTimeField()

    @property
    def status(self):
        return self._status

    @status.setter
    def status(self, status):
        self._status = status
        self.status_changed_at = timezone.now()

thing = Thing()
thing.status = STATUS.published

En fonction des cas, l'utilisation de signaux peut être appropriée, mais vous avez compris le principe de base.

Déploiement et production

Créer un script de déploiement qui ne nécessite qu'une commande

Quelle est la difficulté de déployer votre projet en recette et / ou production ? Dans le cas d'un logiciel SaaS, le déploiement ne devrait pas nécessiter plus d'une seule commande.

Une procédure de déploiement, même bien documentée, n'est pas suffisante si elle est exécutée par des humains. Les humains commettent des erreurs et tôt ou tard, un déploiement va foirer.

Si votre déploiement est aussi simple qu'une seule commande à taper, alors vous aurez la possibilité de déployer tous les jours voire plusieurs fois par jour. Atteindre ce niveau d'agilité apporte une sérénité d'esprit vraiment appréciable.

Conclusion

Cette liste est loin d'être exhaustive, et chaque élément reste à appliquer en conscience en fonction du contexte du projet. Je me réserve d'ailleurs le droit de changer d'avis si quelqu'un me montre de meilleures façons de faire.

Si d'autres idées vous viennent à l'esprit, libre à vous de me les communiquer.

Mobilier urbain de la SNCF