Faire un ModelField Django encodé

Django est plein de surprises. Sa class Model est impressionnante de
complexité. Voici un exemple de ce qui est possible de faire:

# utilisation
class MonModel(models.Model):
    ...
    data = EncryptedField(encoder=Base64Enc)
    ...

mon_instance = MonModel()
mon_instance.data = "coucou"
print(mon_instance.data)
# 'coucou'
print(mon_instance.data_enc)
# 'Y291Y291\n'

Moi, ça me botte. Au final, ça s'apparente à ce que Django fait pour les mots de passe de la base User avec la méthode set_password,
mais en plus pratique.
On va voir le détail des opérations pour arriver à ce résultat.

Tout d'abord, il faut implémenter un objet qui permet d'encoder et de décoder des messages : un encodeur. Pour l'exemple, on va en faire un que ne fait
strictement rien, et un objet qui crée une représentation base64 du contenu.

from django.db import models
import base64

class BaseEnc(object):
    def encode(self, value):
        return value

    def decode(self, msg):
        return msg

class Base64Enc(object):
    def encode(self, value):
        return base64.encodestring(value)

    def decode(self, msg):
        return base64.decodestring(msg)

Maintenant, on crée une sous-classe de TextField et on surcharge quelques méthodes.

class EncodedField(models.TextField):

    description = "An encoded field"

    def __init__(self, encoder=BaseEnc, *args, **kwargs):
        self.encoder = encoder()
        super(EncodedField, self).__init__(*args, **kwargs)

    def contribute_to_class(self, cls, name):
        if self.db_column is None:
            self.db_column = name
        self.field_name = name + '_enc'
        super(EncodedField, self).contribute_to_class(cls, self.field_name)
        setattr(cls, name, property(self.get_data, self.set_data))

    def get_data(self, obj):
        if getattr(obj, self.field_name):
            return self.encoder.decode(getattr(obj, self.field_name))
        return None

    def set_data(self, obj, data):
        if data:
            setattr(obj, self.field_name, self.encoder.encode(data))
        else:
            setattr(obj, self.field_name, None)

La magie opère dans la méthode contribute_to_class. La raison d'être de cette méthode est très bien expliquée dans cet article d'Alex Gaynor. Pour ceux d'entre vous qui ne seraient pas anglophones, ou qui auraient la flemme de tout lire voici un raccourci :

Certaines classes de Django possède une méthode add_to_class. C'est le cas des classes qui héritent de ModelBase par exemple.
Lorsque vous ajoutez un attribut data = MaValeur à un modèle MonModel, add_to_class('data', MaValeur) est appelée.
Deux cas de figurent se présentent, soit MaValeur ne possède pas de méthode contribute_to_class, soit cette méthode existe.

Dans le premier cas, add_to_class va simplement faire un setattr('data', MaValeur). Dans le second, c'est contribute_to_class qui va être appelée.

Ok, maintenant que c'est clair, on reprend.

Notre méthode contribute_to_class va faire plusieurs choses:

  • changer l'attribut field_name de notre attribut en ajoutant "_enc" au nom de l'attribut
  • définir un getter/setter attaché au nom de l'attribut telle qu'on l'a définit dans le modèle

Par exemple, pour un attribut appelé data, les données enregistrées en DB se retrouveront dans l'attribut data_enc, tandis que l'attribut data retournera le resultat de instance_de_champ.get_data('data_enc'). Et voilà, notre valeur est décodée !