Filtrer par nombre d'objets liés dans l'administration de Django

publié le 26 June 2011

[en] In a recent Django project with a one-to-many relationship, I wanted to use the nice filters in the right sidebar of the change list page of the admin to filter by the number of related objects. It happened to be a little bit more tricky than I'd thought, but I could solve the problem with some DB denormalization.

J'ai récemment réalisé (en Django, bien entendu!) une application de gestion des contacts pour une association culturelle.

Dans cette application, chaque contact peut se voir attacher un ou plusieurs problèmes, signalant qu'un courrier/mail/SMS n'a pas pu lui être délivré.

class Contact(models.Model):
    [...]

class Problem(models.Model):    
    contact = models.ForeignKey('Contact')
    date = models.DateField(default=date.today)
    [...]

En règle générale, un seul problème ne nécessite pas d'intervention particulière: il peut s'agir d'une boîte mail pleine ou autre erreur passagère (sur plusieurs milliers de contacts, cela arrive très souvent). Mais la règle dans cette association veut qu'à partir de trois problèmes, les données du contact doivent être ré-étudiées.

Ce qu'on voudrait...

Une manière simple et efficace pour gérer cela semblait d'utiliser les possibilités de filtrage intégreés à l'admin de Django:

list_filter = ('etat', 'groups', ???, 'pays',)

Mais que mettre à la place de ??? pour obtenir l'effet voulu?

Ce qui ne marche pas

J'ai d'abord pensé à mettre quelque chose comme problem_set ou des variantes de cette solution. Oubliez tout de suite, list_filter nécessite un nom de champ pour fonctionner.

J'ai également essayé de simuler la présence du champ qui m'intéresse à l'aide d'un Manager et des fonctions d'agrégation:

class ContactManager(models.Manager):
    def get_query_set(self):
        return super(ContactManager, self).get_query_set().annotate(nb_pb=Count('problem'))

... mais je confirme: list_filter nécessite le nom d'un vrai champ pour fonctionner.

Alors quoi, renoncer? ce n'est pas mon genre ;-)

La solution

La solution ultime, bien entendu, c'est les custom list filters qui devraient apparaître dans Django 1.4. Mais comme j'en ai besoin maintenant déjà...

Il reste la dénormalisation. Heureusement, le mécanisme des signaux de Django nous permet de faire cela très simplement:

class Contact(models.Model):
    [...]
    # denormalized pb count connected by signal...
    nb_pb = models.PositiveIntegerField(default=0)

def update_pb_count(instance, **kwargs):
    c = instance.contact
    c.nb_pb = c.problem_set.count()
    c.save()

signals.post_save.connect(update_pb_count, sender=Problem) 
signals.post_delete.connect(update_pb_count, sender=Problem)

... et hop! notre classe Contact dispose désormais d'un vrai champ nb_pb qui recense le nombre de problèmes liés à ce contact. Ce champ sera mis à jour chaque fois que l'on crée, modifie ou détruit un Problem lié à ce contact.

Il ne reste donc plus qu'à adapter notre ContactAdmin:

list_filter = ('etat', 'groups', 'nb_pb', 'pays',)

... et ça marche!

PS: On s'en sort finalement avec une quantité de code très raisonnable, peut-être même moins qu'avec les futurs custom list filters. Mais la quantité de code ne fait pas tout... et ce sera bien plus élégant avec cette nouvelle fonctionnalité!

Voir aussi: