ForeignKey cross-database avec Django

Django permet d'utiliser plusieurs bases de données pour un même projet, certains modèles seront stockés dans une base, d'autres dans une autres.

Parmis les applications intéressantes, utiliser les données groupes/utilisateurs provenant d'un LDAP et une base de données relationnelle pour les modèles "métier" de l'application.

Contexte

Il suffit de les définir dans settings.py (ici une base sqlite3 et un annuaire LDAP, qui nécessite, utilisant le module django-ldapdb).

Voici un extrait de settings.py associé :

DATABASES = {
    'ldap': {
        'ENGINE': 'ldapdb.backends.ldap',
        'NAME': 'ldap://localhost',
        'USER': '',
        'PASSWORD': '',
        'BASE_DN' : 'ou=user,dc=example,dc=net'
     },
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': 'tatami.db',
        'USER': '',
        'PASSWORD': '',
        'HOST': '', 
        'PORT': '',
    }
  }
DATABASE_ROUTERS = ['ldapdb.router.Router']

Noter la dernière ligne, il s'agit de l'utilisation d'un routeur, (notion django-ienne) qui permettra à Django de prendre une décision quand à la base de données à utiliser pour tel ou tel modèle. Il est possible facilement d'écrire facilement son propre routeur pour gérer ça finement.

Si j'écris mes deux modèles :

import ldapdb.models

class LdapUser(ldapdb.models.Model):
    base_dn = "ou=user,dc=example,dc=net"
    object_classes = ['inetOrgPerson']

    login = CharField(db_column='cn', primary_key=True, max_length=200)

class Membership(models.Model):
    member = models.ForeignKey(LdapUser)</pre></code>

Jusqu'ici tout va bien…

Le problème

Si, par exemple, j'essaye de créer un Membership dans l'interface d'admin django. Patatras :

DatabaseError at /admin/members/membership/add/ no such table: membership_ldapuser

Après inspection minutieuse de Django ((ndlr: mettre des print "coucou" partout dans le code de Django)), il s'avère que lors de la validation du modèle avant sauvegarde, l'existence de la ForeignKey est vérifiée (normal), mais que cette vérification s'opère forcément dans la même base de données que le modèle contenant (Membership).

Le problème est connu et ne sera pas résolu, Django ne supportant pas les relations inter-bdd par choix de conception

Mais si on veut quand-même ?

On peut faire un petit hack en héritant de ForeignKey pour changer ce qui ne nous va pas :

class CrossDbForeignKey(models.ForeignKey):
    def validate(self, value, model_instance):
        if self.rel.parent_link:
            return
        super(models.ForeignKey, self).validate(value, model_instance)
        if value is None:
            return

        # Here is the trick, get db relating to fk, not to root model
        using = router.db_for_read(self.rel.to, instance=model_instance)

        qs = self.rel.to._default_manager.using(using).filter(
                **{self.rel.field_name: value}
             )
        qs = qs.complex_filter(self.rel.limit_choices_to)
        if not qs.exists():
            raise exceptions.ValidationError(self.error_messages['invalid'] % {
                'model': self.rel.to._meta.verbose_name, 'pk': value})

et en utilisant ensuite ce champ :

class Membership(models.Model):
    member = CrossDbForeignKey(LdapUser)

Hop, comme ça c'est un hack mais un hack "contenu qui dit ce qu'il fait"… et puis dans mon cas, pouvoir utiliser plusieurs bases de données sans pouvoir lier les utilisateurs LDAP aux autres modèles limitait grandement l'intérêt du multi-db…