Cet article est une mise à jour d’une précédente publication : créer un blog avec Django, basée sur la version 1.1 du framework. La version 1.2 requière de légères modifications.

Introduction

Créer un blog avec Django, c’est simple. Oui, vraiment. Ce tutorial s’adresse aux personnes connaissant déjà un peu le langage de programmation Python et disposant d’un environnement de développement Django opérationnel.

Brièvement, cela se résume à :

  • Python (interpréteur, libraires… )
  • Un gestionnaire de bases de données et son binding
  • Un terminal (ou “console”)
  • Un navigateur web
  • Un éditeur de texte

Si vous désirez installer Django sur votre machine, n’hésitez pas à consulter la section Quick install guide de la documentation.

Mise en place du projet

Dans ce tutoriel, nous allons utiliser MySQL comme gestionnaire de bases de données mais vous pouvez utiliser n’importe quel autre gestionnaire de bases de données supporté par Django.

Il faut donc, tout d’abord, créer une base de données :

$ mysql -u user -p
mysql> CREATE DATABASE blog;

Remplacez user par l’utilisateur qui va bien (root ou un utilisateur ayant les droits de création de base). Comme il vaut mieux éviter d’utiliser l’utilisateur root, même en local, nous allons créer un utilisateur spécifique pour la base et lui donner les permissions nécessaires. Nous allons nommer cet utilisateur blog, du même nom que la base :

mysql> GRANT ALL ON blog.* TO blog@localhost IDENTIFIED BY 'password';
mysql> FLUSH PRIVILEGES;
mysql> \q

Bien sûr, remplacez password par un vrai mot de passe.

Notre base est créée.

Dans un dossier de votre répertoire personnel (si possible, dans un dossier dédié à vos projets de programmation), nous allons créer le projet Django website et l’application blog :

$ django-admin startproject website
$ cd website
$ django-admin startapp blog

Dans le répertoire du projet website, nous allons créer un dossier templates (qui contiendra nos templates) et un dossier media qui contiendra les fichiers statiques :

$ mkdir templates
$ mkdir media

Pour bien séparer nos applications, nous allons les placer dans un répertoire apps à la racine du projet :

$ mkdir apps
$ touch apps/__init__.py
$ mv blog apps/

La commande tree devrait retourner cette arborescence :

.
|-- __init__.py
|-- apps
|   |-- __init__.py
|   `-- blog
|       |-- __init__.py
|       |-- models.py
|       `-- views.py
|-- manage.py
|-- media
|-- settings.py
|-- templates
`-- urls.py

Le projet est en place. Exécutez la commande suivante :

$ python manage.py runserver

Dans votre navigateur, pointez l’adresse : http://127.0.0.1:8000.

Bienvenue sous Django ! Control + C pour stopper le serveur.

Paramètres et URLs

Éditez le fichier settings.py à l’aide de votre éditeur favori.

Tout d’abord, on crée la constante PROJECT_PATH (en haut du fichier) afin de stocker le chemin absolu vers notre projet :

import os.path
PROJECT_PATH = os.path.dirname(os.path.abspath(__file__))

C’est pratique si vous utilisez différents systèmes d’exploitation ou différentes machines. Le chemin est automatiquement détecté.

Ensuite, on passe à la base de données :

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'blog',
        'USER': 'blog',
        'PASSWORD': 'mot-de-passe',
        'HOST': '',
        'PORT': '',
    }
}

On ajuste la timezone :

TIME_ZONE = 'Europe/Paris'

On ajuste la langue par défaut :

LANGUAGE_CODE = 'fr-fr'

On ajoute notre répertoire media :

MEDIA_ROOT = os.path.join(PROJECT_PATH, 'media/')

On ajoute l’URL vers les médias :

MEDIA_URL = '/media/'

On ajuste l’URL vers les médias de l’interface d’administration :

ADMIN_MEDIA_PREFIX = '/media/admin/'

On ajoute notre répertoire templates :

TEMPLATE_DIRS = (
    os.path.join(PROJECT_PATH, 'templates'),
)

On ajoute notre application blog à la liste INSTALLED_APPS :

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.messages',
    'django.contrib.admin',
    'website.apps.blog',
)

On enregistre les modifications et on passe aux URLs.

Éditez le fichier urls.py. Nous allons ajouter le support des médias. Django va donc prendre en charge les fichiers statiques (pratique quand on développe en local mais à proscrire en production). Pour ce faire, on importe le module settings pour récupérer MEDIA_ROOT (le chemin absolu vers le répertoire media) et on ajoute un urlpatterns pour django.views.static.serve :

from django.conf.urls.defaults import *
from django.conf import settings

# Uncomment the next two lines to enable the admin:
# from django.contrib import admin
# admin.autodiscover()


urlpatterns = patterns('',
    # Example:
    # (r'^website/', include('website.foo.urls')),

    # Uncomment the admin/doc line below and add 'django.contrib.admindocs'
    # to INSTALLED_APPS to enable admin documentation:
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')),

    # Uncomment the next line to enable the admin:
    # (r'^admin/(.*)', include(admin.site.root)),
)

urlpatterns += patterns('',
    (r'^media/(?P<path>.*)$',
        'django.views.static.serve', {
            'document_root': settings.MEDIA_ROOT,
            'show_indexes': True,
        },
    ),
)

On enregistre les modifications et on passe à l’installation de l’interface d’administration.

Installation de l’interface d’administration

Django embarque une interface d’administration sympathique et pratique. L’installation se fait en trois étapes : ajout de l’application dans le fichier settings.py, ajout des URLs et synchronisation de la base de données.

Éditez le fichier settings.py et ajoutez django.contrib.admin dans la liste INSTALLED_APPS :

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.admin',
    'website.apps.blog',
)

Enregistrez les modifications.

Éditez le fichier urls.py et ajoutez le support de l’admin en décommentant les lignes indiquées dans les commentaires, soit trois lignes au total :

from django.conf.urls.defaults import *
from django.conf import settings

# Uncomment the next two lines to enable the admin:
from django.contrib import admin
admin.autodiscover()


urlpatterns = patterns('',
    # Example:
    # (r'^website/', include('website.foo.urls')),

    # Uncomment the admin/doc line below and add 'django.contrib.admindocs'
    # to INSTALLED_APPS to enable admin documentation:
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')),

    # Uncomment the next line to enable the admin:
    (r'^admin/(.*)', include(admin.site.root)),
)

urlpatterns += patterns('',
    (r'^media/(?P<path>.*)$',
        'django.views.static.serve', {
            'document_root': settings.MEDIA_ROOT,
            'show_indexes': True,
        },
    ),
)

Enregistrez les modifications.

Il ne reste plus qu’à synchroniser avec la base de données (à exécuter à la racine du projet) :

$ python manage.py syncdb

Django vous guidera dans la création d’un compte super-utilisateur.

Lancez le serveur :

$ python manage.py runserver

Dans votre navigateur, pointer l’adresse : http://127.0.0.1:8000/admin/. Entrez votre identifiant et votre mot de passe super-utilisateur. Bienvenue dans l’interface d’administration de Django !

Écriture des tests

Oui, écrire les tests avant le code, c’est mieux. Ça permet d’éviter des bogues et des prises de tête. Le but est le suivant : faire en sorte que tous les tests passent. Prêt ? Alors créez un fichier tests.py à la racine de l’application blog. On commence par importer la classe TestCase du module django.test et on crée une classe BlogTest qui contiendra nos tests :

# -*- coding: utf-8 -*-
from django.test import TestCase


class BlogTest(TestCase):
    pass

On lance les tests (à exécuter à la racine du projet) :

$ python manage.py test
Creating test database...
Creating table auth_permission
Creating table auth_group
Creating table auth_user
Creating table auth_message
Creating table django_content_type
Creating table django_session
Creating table django_site
Creating table django_admin_log
Installing index for auth.Permission model
Installing index for auth.Message model
Installing index for admin.LogEntry model
................
----------------------------------------------------------------------
Ran 16 tests in 2.134s

OK
Destroying test database...

Tous les tests passent ! Normal, nous n’en avons écrit aucun. Au boulot !

Notre classe de test :

# -*- coding: utf-8 -*-
from django.test import TestCase
from django.core.urlresolvers import reverse


class BlogTest(TestCase):
    """
    Tests of ``blog`` application.
    """
    fixtures = ['test_data']

    def test_entry_archive_index(self):
        """
        Tests ``entry_archive`` view.

        """
        response = self.client.get(reverse('blog'))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/entry_archive.html')

    def test_entry_archive_year(self):
        """
        Tests ``entry_archive_year`` view.
        """
        response = self.client.get(reverse('blog_year', args=['2010']))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/entry_archive_year.html')

    def test_entry_archive_month(self):
        """
        Tests ``entry_archive_month``view.
        """
        response = self.client.get(reverse('blog_month', args=['2010', '07']))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/entry_archive_month.html')

    def test_entry_archive_day(self):
        """
        Tests ``entry_archive_day`` view.
        """
        response = self.client.get(reverse('blog_day', args=['2010', '07', '21']))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/entry_archive_day.html')

    def test_entry_detail(self):
        """
        Tests ``entry_detail`` view.
        """
        response = self.client.get(reverse('blog_entry', args=['2010', '07', '21', 'test-entry']))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/entry_detail.html')

    def test_entry_detail_not_found(self):
        """
        Test ``entry_detail`` view with an offline entry.
        """
        response = self.client.get(reverse('blog_entry', args=['2010', '07', '21', 'offline-entry']))
        self.failUnlessEqual(response.status_code, 404)

    def test_category_detail(self):
        """
        Tests ``category_detail`` view.
        """
        response = self.client.get(reverse('blog_category', args=['test']))
        self.failUnlessEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'blog/category_detail.html')

    def test_category_detail_not_found(self):
        """
        Tests ``category_detail`` view with an offline category.
        """
        response = self.client.get(reverse('blog_category', args=['offline']))
        self.failUnlessEqual(response.status_code, 404)

La fonction reverse est utilisée pour récupérer l’URL en fonction de son nom (URLs nommées). Pour en savoir plus, n’hésitez pas à consulter la section URL dispatcher de la documentation. Nous testons ici la réponse et le template (pour vérifier que la future vue renverra bien le bon template). Si vous relancez les tests, vous devriez vous faire insulter. C’est normal. Nous n’avons encore rien implémenté. Donc, passons à l’implémentation.

Création des modèles

Nous allons réaliser un blog “basique” composé de deux modèles : Entry et Category. Le premier modèle représente un billet de blog et le deuxième une catégorie pour classer les billets par thème.

Un billet est composé des champs suivants :

  • Un titre
  • Un slug (aussi appelé “permalien”)
  • Un auteur
  • Une catégorie
  • Une date de création
  • Une date de modification
  • Une date de publication
  • Un statut (en ligne / hors ligne)
  • Un corps au format HTML

Une categorie est composée des champs suivants :

  • Un nom
  • Un slug (aussi appelé “permalien”)
  • Une date de création
  • Une date de modification

Éditez le fichier models.py du répertoire blog. Ce fichier contiendra les modèles de notre application. Pour en savoir plus, n’hésitez pas à consulter la section Writing models de la documentation.

Nos modèles :

# -*- coding: utf-8 -*-
"""
Models of ``blog`` application.
"""
from datetime import datetime

from django.db import models
from django.utils.translation import ugettext_lazy as _


class Category(models.Model):
    """
    A blog category.
    """
    name = models.CharField(_('name'), max_length=255)
    slug = models.SlugField(_('slug'), max_length=255, unique=True)
    creation_date = models.DateTimeField(_('creation date'), auto_now_add=True)
    modification_date = models.DateTimeField(_('modification date'), auto_now=True)

    class Meta:
        verbose_name = _('category')
        verbose_name_plural = _('categories')

    def __unicode__(self):
        return u'%s' % self.name

    @models.permalink
    def get_absolute_url(self):
        return ('blog_category', (), {
            'slug': self.slug,
        })


class Entry(models.Model):
    """
    A blog entry.
    """
    STATUS_OFFLINE = 0
    STATUS_ONLINE = 1
    STATUS_DEFAULT = STATUS_OFFLINE
    STATUS_CHOICES = (
        (STATUS_OFFLINE, _('Offline')),
        (STATUS_ONLINE, _('Online')),
    )

    title = models.CharField(_('title'), max_length=255)
    slug = models.SlugField(_('slug'), max_length=255, unique_for_date='publication_date')
    author = models.ForeignKey('auth.User', verbose_name=_('author'))
    category = models.ForeignKey(Category, verbose_name=_('category'))
    creation_date = models.DateTimeField(_('creation date'), auto_now_add=True)
    modification_date = models.DateTimeField(_('modification date'), auto_now=True)
    publication_date = models.DateTimeField(_('publication date'), default=datetime.now(), db_index=True)
    status = models.IntegerField(_('status'), choices=STATUS_CHOICES, default=STATUS_DEFAULT, db_index=True)
    body = models.TextField(_('body'))

    class Meta:
        verbose_name = _('entry')
        verbose_name_plural = _('entries')

    def __unicode__(self):
        return u'%s' % self.title

    @models.permalink
    def get_absolute_url(self):
        return ('blog_entry', (), {
            'year': self.publication_date.strftime('%Y'),
            'month': self.publication_date.strftime('%m'),
            'day': self.publication_date.strftime('%d'),
            'slug': self.slug,
        })

La syntaxe du langage Python est tellement clean que le code parle de lui-même.

Nos modèles sont i18n-ready (via la fonction magique _()).

Avant de créer nos tables, il est recommandé de vérifier si les modèles ne comportent aucune erreur. Pour ce faire, à la racine du projet, on exécute la commande suivante :

$ python manage.py validate
0 errors found

Si cette commande renvoie des erreurs, il suffira de les corriger.

Tout est OK. On synchronise avec la base de données:

$ python manage.py syncdb
Creating table blog_category
Creating table blog_entry
Installing index for blog.Entry model

Par la suite, dans nos templates, nous afficheront uniquement les billets ayant pour statut “en ligne”. Lors de la récupération de nos objets, on peut très bien filtrer sur ce champ. Mais parce qu’on est feignant, on va créer des managers pour s’épargner du code.

Les méthodes d’un manager s’appliquent à une table, tandis que les méthodes d’un modèle s’appliquent à un objet. Donc, si nous voulons récupérer tous les billets ayant pour statut “en ligne”, nous avons besoin d’un manager. Si nous voulons récupérer le nom complet de l’auteur du billet, nous devons définir une méthode spécifique dans le modèle.

Nous avons besoin de deux managers : un pour manipuler uniquement les billets “en ligne” et un autre pour manipuler uniquement les catégories ayant des billets “en ligne” (c’est-à-dire que si nous rédigeons un seul billet dans une catégorie et que ce billet est “hors ligne”, la catégorie ne doit pas exister publiquement).

Dans le répertoire de notre application, on crée un fichier (ou plutôt, un module) nommé managers.py. N’hésitez pas à consulter la section Managers de la documentation pour en savoir plus.

Nos managers :

# -*- coding: utf-8 -*-
"""
Managers of ``blog`` application.
"""
from django.db import models


class CategoryOnlineManager(models.Manager):
    """
    Manager that manages online ``Category`` objects.
    """

    def get_query_set(self):
        from website.apps.blog.models import Entry
        entry_status = Entry.STATUS_ONLINE
        return super(CategoryOnlineManager, self).get_query_set().filter(
            entry__status=entry_status).distinct()


class EntryOnlineManager(models.Manager):
    """
    Manager that manages online ``Entry`` objects.
    """

    def get_query_set(self):
        return super(EntryOnlineManager, self).get_query_set().filter(
            status=self.model.STATUS_ONLINE)

Il faut maintenant ajouter ces managers dans nos modèles :

# -*- coding: utf-8 -*-
"""
Models of ``blog`` application.
"""
from datetime import datetime

from django.db import models
from django.utils.translation import ugettext_lazy as _

from website.apps.blog.managers import CategoryOnlineManager
from website.apps.blog.managers import EntryOnlineManager


class Category(models.Model):
    """
    A blog category.
    """
    name = models.CharField(_('name'), max_length=255)
    slug = models.SlugField(_('slug'), max_length=255, unique=True)
    creation_date = models.DateTimeField(_('creation date'), auto_now_add=True)
    modification_date = models.DateTimeField(_('modification date'), auto_now=True)

    objects = models.Manager()
    online_objects = CategoryOnlineManager()

    class Meta:
        verbose_name = _('category')
        verbose_name_plural = _('categories')

    def __unicode__(self):
        return u'%s' % self.name

    @models.permalink
    def get_absolute_url(self):
        return ('blog_category', (), {
            'slug': self.slug,
        })


class Entry(models.Model):
    """
    A blog entry.
    """
    STATUS_OFFLINE = 0
    STATUS_ONLINE = 1
    STATUS_DEFAULT = STATUS_OFFLINE
    STATUS_CHOICES = (
        (STATUS_OFFLINE, _('Offline')),
        (STATUS_ONLINE, _('Online')),
    )

    title = models.CharField(_('title'), max_length=255)
    slug = models.SlugField(_('slug'), max_length=255, unique_for_date='publication_date')
    author = models.ForeignKey('auth.User', verbose_name=_('author'))
    category = models.ForeignKey(Category, verbose_name=_('category'))
    creation_date = models.DateTimeField(_('creation date'), auto_now_add=True)
    modification_date = models.DateTimeField(_('modification date'), auto_now=True)
    publication_date = models.DateTimeField(_('publication date'), default=datetime.now(), db_index=True)
    status = models.IntegerField(_('status'), choices=STATUS_CHOICES, default=STATUS_DEFAULT, db_index=True)
    body = models.TextField(_('body'))

    objects = models.Manager()
    online_objects = EntryOnlineManager()

    class Meta:
        verbose_name = _('entry')
        verbose_name_plural = _('entries')

    def __unicode__(self):
        return u'%s' % self.title

    @models.permalink
    def get_absolute_url(self):
        return ('blog_entry', (), {
            'year': self.publication_date.strftime('%Y'),
            'month': self.publication_date.strftime('%m'),
            'day': self.publication_date.strftime('%d'),
            'slug': self.slug,
        })

Nous avons des modèles, des managers, une interface d’administration… Ah tiens, et si on ajoutait nos modèles dans l’admin ? Il serait peut-être temps de rédiger quelques billets et de créer quelques catégories pour nos tests.

Ajout des modèles dans l’interface d’administration

Pour ajouter nos modèles dans l’interface d’administration, nous devons créer une classe de type ModelAdmin par modèle. Chaque classe embarquera des options et des méthodes propres à l’admin. Par convention, on placera ces classes dans un module admin.py dans le répertoire de l’application. N’hésitez pas à consulter la section The Django admin site de la documentation pour en savoir plus.

Créez le fichier admin.py dans le répertoire blog.

Nos classes admin :

# -*- coding: utf-8 -*-
"""
Administration interface options of ``blog`` application.
"""
from django.contrib import admin

from website.apps.blog.models import Category
from website.apps.blog.models import Entry


class CategoryAdmin(admin.ModelAdmin):
    """
    Administration interface options of ``Category`` model.
    """
    pass


class EntryAdmin(admin.ModelAdmin):
    """
    Administration interface options of ``Entry`` model.
    """
    pass


admin.site.register(Category, CategoryAdmin)
admin.site.register(Entry, EntryAdmin)

Pour l’instant, on se contente du minimum. Il est possible de presque tout personnaliser. Nous allons quand même améliorer un peu. Voici une version un peu plus peaufinée :

# -*- coding: utf-8 -*-
"""
Administration interface options of ``blog`` application.
"""
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _

from website.apps.blog.models import Category
from website.apps.blog.models import Entry


class CategoryAdmin(admin.ModelAdmin):
    """
    Administration interface options of ``Category`` model.
    """
    list_display = ('name', 'slug', 'creation_date', 'modification_date')
    search_fields = ('name',)
    date_hierarchy = 'creation_date'
    save_on_top = True
    prepopulated_fields = {'slug': ('name',)}


class EntryAdmin(admin.ModelAdmin):
    """
    Administration interface options of ``Entry`` model.
    """
    list_display = ('title', 'category', 'status', 'author')
    search_fields = ('title', 'body')
    date_hierarchy = 'publication_date'
    fieldsets = (
        (_('Headline'), {'fields': ('author', 'title', 'slug', 'category')}),
        (_('Publication'), {'fields': ('publication_date', 'status')}),
        (_('Body'), {'fields': ('body',)}),
    )
    save_on_top = True
    radio_fields = {'status': admin.VERTICAL}
    prepopulated_fields = {'slug': ('title',)}

admin.site.register(Category, CategoryAdmin)
admin.site.register(Entry, EntryAdmin)

Maintenant qu’on peut créer des billets et des catégories, nous allons en profiter pour créer des fixtures pour nos tests. Les fixtures sont des données de test.

Création des fixtures

On crée deux catégories. La première :

  • Titre : Test
  • Slug : test

La seconde :

  • Titre : Offline
  • Slug : offline

Et deux billets. Le premier :

  • Titre : Test Entry
  • Slug : test-entry
  • Catégorie : Test
  • Date de publication : 2010-07-21 00:00:00
  • Statut : en ligne
  • Corps : peu importe, ce que vous voulez

Le second :

  • Titre : Offline
  • Slug : offline-entry
  • Catégorie : Offline
  • Date de publication : 2010-07-21 00:00:00
  • Statut : hors ligne
  • Corps : peu importe, ce que vous voulez

Une fois ces données sauvegardées, on va les exporter au format JSON pour pouvoir les réutiliser automatiquement dans nos tests.

Créons tout d’abord un répertoire fixtures dans le répertoire de l’application.

Puis, exécutez cette commande à la racine du projet :

$ python manage.py dumpdata blog --indent=4 > apps/blog/fixtures/test_data.json

Nos fixtures sont prêtes. Passons aux URLs.

Création des URLs

Nous n’avons même pas besoin de créer de vue pour notre application puisque nous allons utiliser les vues génériques de Django. N’hésitez pas à consulter les sections Generic Views et URL dispatcher de la documentation pour en savoir plus.

Par convention, les URLs seront contenues dans le module urls.py dans le répertoire de l’application (ce fichier n’existe pas, donc pensez à le créer).

Nos URLs :

# -*- coding: utf-8 -*-
"""
URLs of ``blog`` application.
"""
from django.conf.urls.defaults import *

from website.apps.blog.models import Entry
from website.apps.blog.models import Category


urlpatterns = patterns('',
    url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)/$',
        'django.views.generic.date_based.object_detail',
        dict(
            queryset=Entry.online_objects.all(),
            month_format='%m',
            date_field='publication_date',
            slug_field='slug',
        ),
        name='blog_entry',
    ),
    url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/$',
        'django.views.generic.date_based.archive_day',
        dict(
            queryset=Entry.online_objects.all(),
            month_format='%m',
            date_field='publication_date',
        ),
        name='blog_day',
    ),
    url(r'^(?P<year>\d{4})/(?P<month>\d{2})/$',
        'django.views.generic.date_based.archive_month',
        dict(
            queryset=Entry.online_objects.all(),
            month_format='%m',
            date_field='publication_date',
        ),
        name='blog_month',
    ),
    url(r'^(?P<year>\d{4})/$',
        'django.views.generic.date_based.archive_year',
        dict(
            queryset=Entry.online_objects.all(),
            make_object_list=True,
            date_field='publication_date',
        ),
        name='blog_year',
    ),
    url(r'^category/(?P<slug>[\w-]+)/$',
        'django.views.generic.list_detail.object_detail',
        dict(
            queryset=Category.online_objects.all(),
            slug_field='slug'
        ),
        name='blog_category',
    ),
    url(r'^$',
        'django.views.generic.date_based.archive_index',
        dict(
            queryset=Entry.online_objects.all(),
            date_field='publication_date',
        ),
        name='blog',
    ),
)

Notre projet n’est pas encore au courant de ces URLs. Éditez le fichier urls.py à la racine du projet et ajoutez le module via la fonction include :

from django.conf.urls.defaults import *
from django.conf import settings

# Uncomment the next two lines to enable the admin:
from django.contrib import admin
admin.autodiscover()


urlpatterns = patterns('',
    # Example:
    # (r'^website/', include('website.foo.urls')),
    (r'', include('website.apps.blog.urls')),

    # Uncomment the admin/doc line below and add 'django.contrib.admindocs'
    # to INSTALLED_APPS to enable admin documentation:
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')),

    # Uncomment the next line to enable the admin:
    (r'^admin/', include(admin.site.urls)),

)

urlpatterns += patterns('',
    (r'^media/(?P<path>.*)$',
        'django.views.static.serve', {
            'document_root': settings.MEDIA_ROOT,
            'show_indexes': True,
        },
    ),
)

Relançons nos tests :

$ python manage.py test

Ça ne passe toujours pas mais vous avez certainement remarqué que les erreurs sont différentes. Vous ne devriez voir que des erreurs de templates. Il y a donc une progression !

Passons à la création des templates.

Création des templates

Nous allons, dans un premier temps, créer uniquement des templates vides. Puis, nous relancerons nos tests pour vérifier si ils passent bien à présent. Il restera alors juste à remplir les templates pour afficher les données.

On se place à la racine du projet et on crée les fichiers :

$ mkdir templates/layout
$ mkdir templates/blog
$ touch templates/layout/base.html
$ touch templates/blog/entry_detail.html
$ touch templates/blog/category_detail.html
$ touch templates/blog/entry_archive.html
$ touch templates/blog/entry_archive_year.html
$ touch templates/blog/entry_archive_month.html
$ touch templates/blog/entry_archive_day.html
$ touch templates/404.html
$ touch templates/500.html

Relançons les tests :

$ python manage.py test

Nos tests passent ! Nous devons maintenant remplir ces templates.

Fichier templates/layout/base.html :

<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Strict//EN' 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd'>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
    <head>
        <title>{% block title %}{% endblock title %} - Django Blog</title>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <link rel="stylesheet" type="text/css" media="all" href="{{ MEDIA_URL }}css/screen.css" />
    </head>
    <body>
        <div id="header">
            <h1><a href="{% url blog %}">Django Blog</a></h1>
        </div>
        <div id="content">
            {% block content %}{% endblock content %}
        </div>
    </body>
</html>

Fichier templates/404.html :

{% extends "layout/base.html" %}
{% load i18n %}
{% block title %}{% trans "404 Not Found" %}{% endblock title %}
{% block content %}
    <p><strong>{% trans "404 Not Found" %}</strong></p>
{% endblock content %}

Fichier templates/500.html :

{% extends "layout/base.html" %}
{% load i18n %}
{% block title %}{% trans "error 500" %}{% endblock title %}
{% block content %}
    <p><strong>{% trans "Error 500" %}</strong></p>
{% endblock content %}

Fichier templates/blog/entry_archive.html :

{% extends "layout/base.html" %}
{% load i18n %}
{% block title %}{% trans "Latest entries" %}{% endblock title %}
{% block content %}
<h2>{% trans "Latest entries" %}</h2>
{% if latest %}
    {% for entry in latest %}
    <div class="entry">
        <h3><a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a></h3>
        <p class="entry-meta">
            {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
            <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </p>
        <div class="entry-body">
            {{ entry.body|safe }}
        </div>
    </div>
    {% endfor %}
{% else %}
    <p><strong>{% trans "No entry yet" %}.</strong></p>
{% endif %}
{% endblock content %}

Fichier templates/blog/entry_archive_year.html :

{% extends "layout/base.html" %}
{% block title %}{{ year }}{% endblock title %}
{% block content %}
<h2>{{ year }}</h2>
<ul>
    {% for entry in object_list %}
    <li>
        <a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> |
        <small>
        {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </small>
    </li>
    {% endfor %}
</ul>
{% endblock content %}

Fichier templates/blog/entry_archive_month.html :

{% extends "layout/base.html" %}
{% block title %}{{ month|date:"Y/m" }}{% endblock title %}
{% block content %}
<h2>{{ month|date:"Y/m" }}</h2>
<ul>
    {% for entry in object_list %}
    <li>
        <a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> |
        <small>
        {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </small>
    </li>
    {% endfor %}
</ul>
{% endblock content %}

Fichier templates/blog/entry_archive_day.html :

{% extends "layout/base.html" %}
{% block title %}{{ day|date:"Y/m/d" }}{% endblock title %}
{% block content %}
<h2>{{ day|date:"Y/m/d" }}</h2>
<ul>
    {% for entry in object_list %}
    <li>
        <a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> |
        <small>
        {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </small>
    </li>
    {% endfor %}
</ul>
{% endblock content %}

Fichier templates/blog/entry_detail.html :

{% extends "layout/base.html" %}
{% block title %}{{ object.title }}{% endblock title %}
{% block content %}
<div class="entry">
    <h2>{{ object.title }}</h2>
    <p class="entry-meta">
        {{ object.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ object.category.get_absolute_url }}">{{ object.category.name }}</a>
    </p>
    <div class="entry-body">
        {{ object.body|safe }}
    </div>
</div>
{% endblock content %}

Fichier templates/blog/category_detail.html :

{% extends "layout/base.html" %}
{% block title %}{{ object.name }}{% endblock title %}
{% block content %}
<h2>{{ object.name }}</h2>
<ul>
    {% for entry in object.entry_set.all %}
    <li>
        <a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> |
        <small>
        {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </small>
    </li>
    {% endfor %}
</ul>
{% endblock content %}

Dans ce dernier template, object.entry_set.all récupére tous les billets liés à cette catégorie. Et oui, tous. Y compris les billets hors ligne. Le plus simple est donc de créer une propriété dans le modèle Category pour ne récupérer que les billets en ligne :

class Category(models.Model):
    """
    A blog category.
    """
    name = models.CharField(_('name'), max_length=255)
    slug = models.SlugField(_('slug'), max_length=255, unique=True)
    creation_date = models.DateTimeField(_('creation date'), auto_now_add=True)
    modification_date = models.DateTimeField(_('modification date'), auto_now=True)

    objects = models.Manager()
    online_objects = CategoryOnlineManager()

    class Meta:
        verbose_name = _('category')
        verbose_name_plural = _('categories')

    def __unicode__(self):
        return u'%s' % self.name

    @models.permalink
    def get_absolute_url(self):
        return ('blog_category', (), {
            'slug': self.slug,
        })

    def _get_online_entries(self):
        """
        Returns entries in this category with status of "online".
        Access this through the property ``online_entry_set``.
        """
        from website.apps.blog.models import Entry
        return self.entry_set.filter(status=Entry.STATUS_ONLINE)

    online_entry_set = property(_get_online_entries)

Voici la nouvelle version du template :

{% extends "layout/base.html" %}
{% block title %}{{ object.name }}{% endblock title %}
{% block content %}
<h2>{{ object.name }}</h2>
<ul>
    {% for entry in object.online_entry_set.all %}
    <li>
        <a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a> |
        <small>
        {{ entry.publication_date|date:"Y/m/d @ H:i:s" }} -
        <a href="{{ entry.category.get_absolute_url }}">{{ entry.category.name }}</a>
        </small>
    </li>
    {% endfor %}
</ul>
{% endblock content %}

Nos templates sont en place. C’est très rustre mais c’est un exemple d’application.

Qu’avons-nous oublié ? Mais oui, bien sûr, les fils RSS ! Un blog sans fils RSS n’est pas un blog. C’est indispensable. Heureusement, Django nous permet d’ajouter cette fonctionnalité en moins de deux minutes. Voyons comment procéder.

Ajout des fils RSS

Django embarque une application pour la génération de fils RSS. N’hésitez pas à consulter la section The syndication feed framework de la documentation pour en savoir plus. Difficile de faire plus simple et efficace.

Commençons par écrire nos tests. Ajoutons les méthodes test_rss_entries et test_rss_category à notre module test.py, placé dans le répertoire de l’application :

def test_rss_entries(self):
    """
    Tests entries RSS feed.
    """
    blog_url = reverse('blog')
    url = u'%sfeed/rss/entries/' % blog_url
    response = self.client.get(url)
    self.failUnlessEqual(response.status_code, 200)

def test_rss_category(self):
    """
    Tests categories RSS feed.
    """
    from website.apps.blog.models import Category
    categories = Category.online_objects.all()
    blog_url = reverse('blog')
    for category in categories:
        url = u'%sfeed/rss/category/%s/' % (blog_url, category.slug)
        response = self.client.get(url)
        self.failUnlessEqual(response.status_code, 200)

Dans notre application blog, créons un module feeds.py. Dans ce module, plaçons nos classes de syndication :

# -*- coding: utf-8
"""
Feeds of ``blog`` application.
"""
from django.utils.feedgenerator import Rss201rev2Feed
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse

from django.contrib.syndication import feeds
from django.contrib.sites.models import Site

from website.apps.blog.models import Entry
from website.apps.blog.models import Category


class RssEntries(feeds.Feed):
    """
    RSS entries.
    """
    feed_type = Rss201rev2Feed
    title_template = "blog/feeds/entry_title.html"
    description_template = "blog/feeds/entry_description.html"

    def title(self):
        """
        Channel title.
        """
        site = Site.objects.get_current()
        return _('%(site_name)s: RSS entries') % {
            'site_name': site.name,
        }

    def description(self):
        """
        Channel description.
        """
        site = Site.objects.get_current()
        return _('RSS feed of recent entries posted on %(site_name)s.') % {
            'site_name': site.name,
        }

    def link(self):
        """
        Channel link.
        """
        return reverse('blog')

    def items(self):
        """
        Channel items.
        """
        return Entry.online_objects.order_by('-publication_date')[:10]

    def item_pubdate(self, item):
        """
        Channel item publication date.
        """
        return item.publication_date


class RssCategory(RssEntries):
    """
    RSS category.
    """
    def title(self, obj):
        """
        Channel title.
        """
        site = Site.objects.get_current()
        return _('%(site_name)s: RSS %(category)s category') % {
            'site_name': site.name,
            'category': obj.name,
        }

    def description(self, obj):
        """
        Channel description.
        """
        site = Site.objects.get_current()
        return _('RSS feed of recent entries posted in the category %(category)s on %(site_name)s.') % {
            'category': obj.name,
            'site_name': site.name,
        }

    def link(self, obj):
        """
        Channel link.
        """
        return reverse('blog_category', args=[obj.slug])

    def get_object(self, bits):
        """
        Object: the Category.
        """
        if len(bits) != 1:
            raise ObjectDoesNotExist
        return Category.online_objects.get(slug=bits[0])

    def items(self, obj):
        """
        Channel items.
        """
        return obj.online_entry_set

    def item_pubdate(self, item):
        """
        Channel item publication date.
        """
        return item.publication_date

Dans le module urls.py de l’application blog, ajoutons le support des fils RSS :

"""
URLs of blog application.
"""
from django.conf.urls.defaults import *

from website.apps.blog.models import Entry
from website.apps.blog.models import Category

from website.apps.blog.feeds import RssEntries
from website.apps.blog.feeds import RssCategory


rss_feeds = {
    'entries': RssEntries,
    'category': RssCategory,
}

urlpatterns = patterns('',
    url(r'^feed/rss/(?P<url>.*)/$',
        'django.contrib.syndication.views.feed', {
            'feed_dict': rss_feeds,
        },
        name='blog_rss_feed',
    ),
    ...
)

Il ne reste plus qu’à créer les templates :

$ mkdir templates/blog/feeds
$ touch templates/blog/feeds/entry_title.html
$ touch templates/blog/feeds/entry_description.html

Le template templates/blog/feeds/entry_title.html :

{{ obj.title }}

Le template templates/blog/feeds/entry_description.html :

{{ obj.body|safe }}

Lançons les tests pour vérifier si tout est OK:

python manage.py test

Vous ne devriez pas rencontrer d’erreur.

Lancez le serveur:

python manage.py runserver

Dans votre navigateur, allez à ces adresses:

  • http://127.0.0.1:8000/feed/rss/entries/
  • http://127.0.0.1:8000/feed/rss/category/test/

Vous devriez voir les fils. Merci Django !

Conclusion

Cet exemple d’application Django est un blog simpliste, basique, lambda. Moult fonctionnalités ont été volontairement omises (formatage du contenu des billets avec une syntaxe wiki, gestion multi-catégories, support des tags, support du format Atom pour les fils de syndication, amélioration de l’interface d’administration, amélioration des templates à l’aide d’includes, support des commentaires, ajout d’une barre de navigation… ) pour ne pas transformer ce tutoriel en ouvrage technique. La documentation de Django est complète et claire. N’hésitez pas à la consulter au moindre problème.

Télécharger le source de l’application.