Mots-clé : django-modèles

Django : le polymorphisme : astuces

Un excellent article écrit par Dan Bader (j’ai pas mal discuté avec lui et j’ai son livre, il est vraiment aussi bon que modeste, je vous recommande vivement tous ses articles – et n’hésitez pas à lui parler en Anglais (tant qu’il n’est pas encore trop connu !), il vous répondra sûrement).

Il manque juste ces « petits » détails techniques lorsqu’on se base sur les modèles abstraits et l’héritage au sens « modèle » de Django (Abstract Base Model).

Prenons un exemple simple : vous créez un modèle « simple » Media(models.Model) dont vous allez hériter sur deux modèles : Book(Media) et Dvd(Media).

Ce qui va vous prendre beaucoup de temps (et qui n’est expliqué nulle part de manière claire et directe), c’est que Django, de manière cachée, va créer des propriétés qui vont dans les deux sens entre le modèle parent et le modèle enfant.

Pour reprendre notre exemple, avec les trois modèles : Media = parent, et les deux enfants Book et Dvd.
Imaginez que pour chaque enfant, vous créiez une méthode spécifique, par exemple Book.nb_pages() et Dvd.is_blue_ray()
De manière cachée, vous pourrez à partir du parent, accéder à l’enfant et ses propriété via son nom comme ceci :
my_media = Media.object.get(pk=1)
if hasattr(self, 'book'):  # this Media is a Book:
    my_media.book.nb_pages()
elif hasattr(self, 'dvd'):  # this media is a Dvd:
    my_media.dvd.is_blue_ray()
else:  # direct Media model access:
    pass

A l’inverse, à partir des modèles enfants, pour accéder au parent c’est le nom du modèle parent avec _ptr :
my_book = Book.object.get(pk=21)
my_dvd = Dvd.object.get(pk=75)
# imaginons que "price" est une propriété de Media property. On peut écrire :
print(my_book.media_ptr.price)
print(my_dvd.media_ptr.price)

Django : un modèle sur mesure : DaysField code source complet

Code source complet de DaysOfWeek

Après avoir écrit en trois parties (1, 2 et 3) comment faire un modèle sur mesure, voici le code source au complet.

from django.core.exceptions import ValidationError
from django import forms
from django.db import models
from django.utils.translation import ugettext_lazy as _
class DaysOfWeek:
    DAYS = {1: _("Monday"),
            2: _("Tuesday"),
            3: _("Wednesday"),
            4: _("Thursday"),
            5: _("Friday"),
            6: _("Saturday"),
            7: _("Sunday"), }
    DAYS_SHORT = {1: _("Mo"),
                  2: _("Tu"),
                  3: _("We"),
                  4: _("Th"),
                  5: _("Fr"),
                  6: _("Sa"),
                  7: _("Su"), }
    CHOICES = [(idx, value) for idx, value in DAYS.items()]
    @staticmethod
    def summary_from_list(tab, empty=' '):
        if tab is None:
            return '-'.join([empty for a in range(len(DaysOfWeek.DAYS))])
        return '-'.join([str(DaysOfWeek.DAYS_SHORT.get(i, empty)) for i in tab])
class DaysFormField(forms.TypedMultipleChoiceField):
    # different widget, comment to change interface:
    widget = forms.CheckboxSelectMultiple
    def __init__(self, *args, **kwargs):
        if 'max_length' in kwargs:
            kwargs.pop('max_length')
        kwargs['choices'] = DaysOfWeek.CHOICES
        super().__init__(*args, **kwargs)
class DaysField(models.CharField):
    description = _("Comma-separated integers between 1 and 7")
    def __init__(self, *args, **kwargs):
        kwargs['max_length'] = 13 # max. len = all days = "1,2,3,4,5,6,7" = 13
        super().__init__(*args, **kwargs)
    @staticmethod
    def value_to_array(value):
        if value is None:
            return None
        try:
            if isinstance(value, list):
                return [int(a) for a in value]
            elif isinstance(value, str):
                return [int(a) for a in value.split(',')]
        except (TypeError, ValueError):
            raise ValidationError(_("Unexpected value"))
        raise ValidationError(_("Unexpected value"))
    @staticmethod
    def from_db_value(value, expression, connection):
        return DaysField.value_to_array(value)
    def to_python(self, value):
        return DaysField.value_to_array(value)
    def get_prep_value(self, value):
        return ','.join([str(a) for a in value]) if value is not None else None
    def formfield(self, **kwargs):
        # ignore the admin directives: directly override with our custom form:
        return super().formfield(form_class=DaysFormField,
                                 initial=[])
    # region - Validator -
    class ListBetween1And7Validator:
        def __call__(self, value):
            try:
                if isinstance(value, list):
                    loop = [int(a) for a in value]
                elif isinstance(value, str):
                    loop = [int(a) for a in value.strip('[]').split(',')]
                else:
                    raise ValueError()
                for v in loop:
                    if not (7 >= v >= 1):
                        raise ValueError()
            except (TypeError, ValueError):
                raise ValidationError(
                    _("Enter a list if coma-separated values between 1 and 7."),
                    code='invalid')
    # endregion - Validator -
    default_validators = [ListBetween1And7Validator()]

Django : un modèle sur mesure : DaysField étape 3/3

Formulaire spécifique pour un modèle sur-mesure

Après les conversions de notre modèle dans les deux sens :

Il ne nous reste qu’à faire le validateur = s’assurer que ce qui arrive (peu importe la source : base de données ou code Python) :

  • est bien un tableau :
  • que ce tableau n’est rempli que d’entiers ;
  • que chacun de ces entiers est compris entre 1 et 7.

Il faut créer une classe, qui doit implémenter __call__. Vous ne savez pas ce que c’est ? L’explication sur SO ici est excellente.
En une phrase : __init__() est appelé lorsque la classe est instanciée, __call__ est appelé lorsque l’instance est appelée (avec des ())

class ListBetween1And7Validator:
    def __call__(self, value):
        try:
            if isinstance(value, list):
                loop = [int(a) for a in value]
            elif isinstance(value, str):
                loop = [int(a) for a in value.strip('[]').split(',')]
            else:
                raise ValueError()
            for v in loop:
                if not (7 >= v >= 1):
                    raise ValueError()
        except (TypeError, ValueError):
            raise ValidationError(
                _("Enter a list if coma-separated values between 1 and 7."),
                  code='invalid')

Puis il faut le déclarer dans la liste des validateurs de notre classe :

    default_validators = [ListBetween1And7Validator()]

Dans le prochain et dernier article, je mets le code source au complet.

Django : un modèle sur mesure : DaysField étape 2/3

Formulaire spécifique pour un modèle sur-mesure

Après les conversions (base / et / ou / Python) vers données Python (dans les deux sens), il nous faut écrire notre formulaire spécifique pour notre modèle DaysField sur-mesure.

Ici, l’utilisateur doit pouvoir cocher / ou pas / des valeurs qui correspondent aux jours de la semaine.

Au jour de l’écriture de ce document, la documentation n’est pas complète.
Officiellement, il « suffirait » de surcharger def formfield(self, **kwargs) en y ajoutant sa propre classe de formulaire via l’index « form_class » de kwargs.

L’exemple proposé ne fonctionne pas !

En effet, lorsqu’on affiche le modèle dans l’interface d’administration, kwargs est déjà pré-rempli avec l’indice « widget » qui est le composant spécifique pour l’administration. Pour que leur exemple fonctionne, il faudrait supprimer « widget » avant d’y ajouter « form_class ».

J’ai opté pour la méthode directe qui ignore « kwargs » :
    def formfield(self, **kwargs):
        # ignore the admin directives: directly override with our custom form:
        return super().formfield(form_class=DaysFormField,
                                 initial=[])

Enfin, la classe DaysFormField elle-même :
class DaysFormField(forms.TypedMultipleChoiceField):
    def __init__(self, *args, **kwargs):
        if 'max_length' in kwargs:
            kwargs.pop('max_length')
        kwargs['choices'] = DaysOfWeek.CHOICES
        super().__init__(*args, **kwargs)

Cela affiche maintenant un choix ainsi :
Custom field DaysField choice 1.jpg

On peut facilement le modifier en y précisant un autre widget, par exemple :
class DaysFormField(forms.TypedMultipleChoiceField):
    widget = forms.CheckboxSelectMultiple
    # (le reste du code avec __init_())

Cela affiche maintenant un choix ainsi :
Custom field DaysField choice 2.jpg

Django : un modèle sur mesure : DaysField étape 1/3

Le principe

Je voulais un modèle de données qui stocke les jours d’une semaine que l’utilisateur désire, et qui les stocke sous forme de chaîne simple, avec des numéros qui correspondent aux jours de la semaine.
J’ai appelé ce modèle DaysField.
Les valeurs sont stockées par rapport aux jours choisis. Par exemple, 1,3 signifie que l’utilisateur a choisi lundi et mercredi parmi les jours. 6,7 correspondent à samedi et dimanche. Pour finir, tous les jours de la semaine cochés serait donc : 1,2,3,4,5,6,7

Toujours descendre d’un modèle classique

J’ai retenu ces principes et cela semble suffire pour fonctionner : on descend du modèle qu’on veut écrire en base de données. Ici, je voulais écrire une chaîne de caractères dans la base ⇒ on descend de CharField(), en modifiant la description au passage :
class DaysField(models.CharField):
    description = _("Comma-separated integers between 1 and 7")

Conversion de « sources » différentes en données Python

Pour résumer : les modèles ont deux « arrivées » de données :

  • quand on les données viennent de la base = from_db_value() ;
  • quand on les données viennent d’un formulaire (ou autre, mais en gros quand ça vient pas de la base de données mais du code Python lui-même) = to_python().

Il faut donc gérer ces deux cas, qui, dans les deux cas, doivent renvoyer quelque chose au format Python qui nous intéresse.
Ici, c’est un tableau d’entiers compris entre 1 et 7 qui correspond aux jours de la semaine.

J’ai centralisé la gestion des deux « arrivées » (base et Python) dans une seule fonction : value_to_array() :

    @staticmethod
    def value_to_array(value):
        if value is None:
            return None
        try:
            if isinstance(value, list):
                return [int(a) for a in value]
            elif isinstance(value, str):
                return [int(a) for a in value.split(',')]
        except (TypeError, ValueError):
            raise ValidationError(_("Unexpected value"))
        raise ValidationError(_("Unexpected value"))
    @staticmethod
    def from_db_value(value, expression, connection):
        return DaysField.value_to_array(value)
    def to_python(self, value):
        return DaysField.value_to_array(value)

Peut-être existe-t-il des cas où la provenance du code Python doit être gérée différemment de la provenance de la base de données… ici ce n’est pas le cas – et si vous avez des exemples de gestion différentes pour des entrées de base et de code, laissez-moi un commentaire, car je n’en vois pas…

Conversion de données Python ⇒ données pour la base

C’est la fonction get_prep_value().

Dans le cas DaysField, c’est très simple :
    def get_prep_value(self, value):
        return ','.join([str(a) for a in value]) \
            if value is not None else None

On prend toutes les valeurs du tableau, on les concatène avec la virgule , comme séparateur. C’est plus long de l’expliquer que de le coder !

Django annotate : à quoi ça sert ?

Je n’avais pas bien compris l’utilité de « annotate » dans les queries Django jusqu’à maintenant…

Voici quel était mon problème :

J’avais un modèle de base, Activite. Une activité contenait des choses concernant… une activité. Si si je vous jure !

Par exemple, une activité consistait à ajouter un voyage. Ou avoir une nouvelle relation. Ou faire un témoignage comme quoi on aimait bien le site. Etc.
Je voulais que ces activités soient « partageables » entre les amis.
J’ai crée un modèles ActiviteShare. Facile jusque là.

Mais lorsque je demandais à récupérer toutes les activités, il fallait que je les trie en me basant sur le fait de savoir si elles étaient partagées. Pour exagérer, imaginons une activité qui était faite en 2010. Vous étiez parti à Malibu. Génial ! Et maintenant, en 2016, un ami s’inscrit, devient votre ami, et vous voulez partager cette activité avec lui. Il faut qu’elle s’affiche dans votre mur, comme activité « partagée récemment », mais qu’elle garde sa date de 2010, ok ?

Donc je devais trier d’abord en fonction de la date de ActiviteShare puis seulement après par la date de création de l’Activite.

Seul hic : si vous aviez partagé cette activité avec 10 contacts, au moment du tri, Django renvoyait dix fois le même enregistrement.

Mon tri était super basique, du genre :

Activite.objects.filter(
    # blabla
).order_by(
    '-activiteshared__date_last_modif',
    '-date_last_modif',
    '-date_publication',)

Mais ça ne marchait pas. 🙁

La solution ? Annotate.

Pour vous expliquer avant de mettre le code : je lui demande, lors de la requête, de garder la date maximale parmi toutes les dates partagées, et de l’appeler « mx ». Ensuite, je demande de trier simplement d’abord par « mx », puis par la date de dernière modification de l’activité.

Activite.objects.filter(
    # blabla
).annotate(mx=Max('activiteshared__date_last_modif')).order_by(
    '-mx',
    '-date_last_modif',
    '-date_publication',)

Merci à ce lien qui m’a bien aidé.

Django : créer un many-to-many self-referencing double way !

Je vous explique, et c’est bien plus facile en français en fait 😉
Je voulais mettre en place une relation style facebook mais en plus évolué : une personne peut avoir une ou plusieurs personnes ami(e)(s). Seulement, on ne gère qu’un seul type de relation par personne. Par exemple, Olivier est ami proche avec Elsa. Déjà, cela implique qu’Elsa est amie proche avec Olivier. Donc, il faut imaginer une table qui aura une relation qui va s’auto-référencer, via une table intermédiaire dans laquelle on précisera le type de relation.

Ce n’est pas très dur à imaginer :

Personne <-> PersonneRelation.

Déclaration de Personne :

class Personne(BaseModel):
    user = models.ForeignKey(User)
    relations = models.ManyToManyField('self',
                                       through='PersonneRelation',
                                       symmetrical=False)

Maintenant, là où le problème se pose c’est qu’au moment de l’ajout d’une relation dans un sens, par exemple mari femme, il faut que la relation soit aussi ajoutée dans l’autre sens, à la fois pour des questions de performance, mais aussi pour des questions d’affichage (« Simon est le mari de Arlette » mais dans l’autre sens, « Arlette est la femme de Simon », on constate que les phrases sont totalement différentes… et quand c’est mari femme, on peut imaginer ne pas se compliquer l’existence en regardant dans si la personne est un homme ou une femme et en déduire le sens, mais si jamais c’est une relation maître – élève ? HEIN ? COMMENT ON FAIT ? Si c’est un formateur qui forme des adultes ? HEIN ? ON DIT QUOI LA ? On fait moins le malin d’un coup HEIN ! Oui bon ok il faut que je décompresse un peu…). Donc l’idée est (1) de définir les relations possibles en dur (vous pourrez très facilement faire évoluer cela en une relation supplémentaire vers une table qui définit le type de relation et vous n’aurez plus de limites en termes de types de relations possibles, mais ce qui suit est déjà assez long à expliquer, je ne vais pas en plus l’alourdir avec du code supplémentaire) et de (2) gérer au moment où on insère un nouvel enregistrement : si jamais la relation opposée n’est pas encore présente, on l’ajoute. Ah. J’oubliais le (3) modifications = appliquer la même de l’autre côté et suppression : supprimer l’autre côté aussi.

Stop bullshit, du code :

@python_2_unicode_compatible
class PersonneRelation(BaseModel):

    TYPE_AMI = u'0'
    TYPE_CONNAISSANCE = u'1'
    TYPE_PARENT_ENFANT = u'2'
    TYPE_MARI_FEMME = u'3'
    TYPE_PROFESSEUR_ELEVE = u'4'
    TAB_TYPES = {
        TYPE_AMI: _(u'friend'),
        TYPE_CONNAISSANCE: _(u'relationship'),
        TYPE_PARENT_ENFANT: _(u'parent > child'),
        TYPE_MARI_FEMME: _(u'husband <> wife'),
        TYPE_PROFESSEUR_ELEVE: _(u'teacher > student'),
    }
    TAB_TYPES_REVERSE = {
        TYPE_AMI: _(u'friend'),
        TYPE_CONNAISSANCE: _(u'relationship'),
        TYPE_PARENT_ENFANT: _(u'child > parent'),
        TYPE_MARI_FEMME: _(u'wife <> husband'),
        TYPE_PROFESSEUR_ELEVE: _(u'student > teacher'),
    }
    type_relation = models.CharField(max_length=1,
                                     choices=[(a, b) for a, b in
                                              list(TAB_TYPES.items())],
                                     default=TYPE_AMI)
    src = models.ForeignKey('Personne', related_name='src')
    dst = models.ForeignKey('Personne', related_name='dst')
    opposite = models.ForeignKey('PersonneRelation',
                                 null=True, blank=True, default=None)
    is_reverse = models.BooleanField(default=False)

    def __str__(self):
        return _(u'n.{} {} --> {}').format(
                str(self.pk),
                self.TAB_TYPES[self.type_relation] if not self.is_reverse
                else self.TAB_TYPES_REVERSE[self.type_relation],
                str(self.dst))

    class Meta:
        verbose_name = _(u'Relation')
        verbose_name_plural = _(u'Relations')

@receiver(post_save, sender=PersonneRelation)
def signal_receiver(sender, **kwargs):
    created = kwargs['created']
    obj = kwargs['instance']
    if created and not obj.opposite:
        opposite = PersonneRelation(
            src=obj.dst, dst=obj.src, opposite=obj,
            type_relation=obj.type_relation, is_reverse=True)
        opposite.save()
        obj.opposite = opposite
        obj.save()
    elif not created and obj.type_relation != obj.opposite.type_relation:
        obj.opposite.type_relation = obj.type_relation
        obj.opposite.save()

Vous remarquerez que j’ai vraiment réindenté le code pour qu’il reste lisible ici !

J’espère qu’il vous servira !

Python : batteries included.
Django : La Plateforme de développement Web pour les perfectionnistes sous pression.