Skip to content

Tuto django et uniformisation

fandejuni edited this page Oct 5, 2016 · 11 revisions

Processus de mise en place d'un composant sur Django, et uniformisation des pratiques

Il y a trois éléments à distinguer quand on manipule Django avec REST, le modèle, le serializer, et la vue. Voici un guide qui permet de définir une utilisation basique mais souvent très très souvent largement suffisante pour notre utilisation.

Ce guide sert aussi d'harmonisation des pratiques, de sorte à ne pas créer plus de classe qu'il n'en faut, ou placer certaines fonctions inutiles, ou au mauvais endroit. A retenir de manière générale :

  • Les droits sont à placer côté modèle
  • La validation des données est à placer côté serializer

Le Modèle (Doc)

Doc : type de champs

Le modèle sert à donner a Django la structure de nos ressources. Il se charge tout seul de manipuler la base de donnée. Le modèle est la classe principale de la ressource. Sur le modèle on placera :

  • Les propriétés de la ressource (pas besoin de définir un id, il existe par défaut sur django)
  • Éventuellement Des fonctions de manipulation de la ressource, et de ses attributs
  • Les droits de la ressource (voir le paragraphe DRYPermission)

Le schéma de base d'une ressource est :

from django.db import models

class MonModel(models.Model):

    ################################################################
    # CONSTANTS                                                    #
    ################################################################
    
    # Liste de constantes à utiliser en cas de choix, par exemple ici, le champ type
    TYPE_1 = 0
    TYPE_2 = 2

    ################################################################
    # FIELDS                                                       #
    ################################################################
    
    # Liste des champs de l'objet
    name = models.CharField(max_length=255)
    group = models.ForeignKey('MonAutreModele')
    type = models.PositiveSmallIntegerField(default=TYPE_0)

    ################################################################
    # PERMISSIONS                                                  #
    ################################################################
    
    def has_object_read_permission(self, request):
        return True
    
    @staticmethod
    def has_read_permission(request):
        return True
    
    def has_object_write_permission(self, request):
        return True

    @staticmethod
    def has_write_permission(request):
        return True

Le serializer (Doc)

Il se charge d'effectuer la transformation : représentation <=> modèle django Ici, il se chargera de transformer une ressource django en json pour l'envoi, et convertir du json en ressource django pour modifier la base de donnée.

Définition du serializer

Cette classe est souvent très vide puisqu'il existe une base RESTFramework qui permet de créer automatiquement le serializer à partir du modèle Django. Important, il ne peut pas automatiquement décrire une relation ! Il faut alors lui expliquer comment il est censé traduire une relation, en code JSON. Le plus souvent, il s'agit du cas OneToMany, où il suffit de lui dire de donner la clé primaire de l'objet pointé.

Exemple type d'un sérializer :

from rest_framework import serializers

from sigma_core.models.monmodel import MonModel
from sigma_core.models.monmodel import MonAutreModel

class MonSerializer(serializers.ModelSerializer):
    class Meta:
        model = MonModel
    
    # On precise que la relation MonModel.group doit se traduire par la clef primaire de l'objet pointe
    group = serializers.PrimaryKeyRelatedField(queryset=MonAutreModel.objects.all())

C'est aussi dans le serializer qu'on effectue la validation des données. Cela passe tout d'abord par la validation de la représentation des données (est-ce que le JSON est bien valide et lisible ?), qui est effectuée naturellement par le serializer. Il y a aussi la vérification des contraintes relationnelles, qui encore une fois, se fait naturellement. Mais on peut aussi rajouter une sur-couche de contrainte, comme par exemple vérifier qu'une date de naissance est entre 1980 et 2000, ou qu'une adresse email est bien formée. Pour cela, il existe deux type de validation :

  • La validation par champ, via la définition d'une fonction def validate_nomduchamp(self, value) qui doit retourner value si le champ est correct, ou lancer une ValidationError : raise serializer.ValidationError("Definir l'erreur")
  • La validation globale, dans certain cas, il semble plus correct de vérifier la validité d'un objet dans son ensemble. Pour cela, il faut définir la fonction def validate(self, data) qui fonctionne de la même manière.

Utilisation du serializer

Dans vos vues, vous pouvez, dans certains cas, être amenés à utiliser le serializer. Voici les grandes étapes à effectuer dans le cas où l'on reçoit des données JSON :

  • Premièrement, la création du serializer avec les données serializer = MonSerializer(data=mesdonnees)
  • Ensuite, effectuer une validation des données en utilisant :
    • serializer.is_valid() qui retourne True ou False selon la validité des données. Dans le cas où elle retourne False, on peut accéder aux erreurs à l'aide de serializer.errors. Si la validation est gérée par champ, l'avantage est que l'on récupère alors une liste de paire (champ, erreur concernant le champ)
    • serializer.is_valid(raise_exception=True) qui va alors, au lieu de retourner False en cas d'erreur, lancer une exception HTTP qui remontera jusqu'à Django pour retourner une erreur 400 Bad Request avec le contenu de l'erreur.
  • Traiter la requête
    • Soit en faisant un serializer.save() qui enregistre alors les changement dans la BDD django
    • Soit en traitant serlializer.validated_data

Dans le cas où l'on veut envoyer des données depuis Django, la démarche est plus simple :

  • La création serializer = MonSerializer(mesdonnes)
  • L'utilisation des données sérializées via serializer.data On remarque que dans ce cas, comme les données proviennent de Django, il n'y pas besoin de les valider, et on utilise alors data et non validated_data

La vue (Doc)

Une vue est une fonction qui gère une requête, et renvoie la réponse appropriée. De manière générale, le protocole de communication étant standardisé (REST), on peut laisser le REST-framework faire les choses pour nous à l'aide du ModelViewset. A partir d'un modèle, et d'un serializer, il crée automatiquement les vues correspondantes au diverses actions.

Les diverses actions sont :

  • Actions de lecture (read)
    • La récupération d'une liste d'élement (list)
    • La récupération d'un élément en particulier (retreive)
  • Actions d'écriture (write)
    • La création (create)
    • L'édition d'un élément en particulier (update)
    • La suppression d'un élement particulier (destroy) On distingue aussi les actions globales (list, create) qui ne prennent pas de paramètre, et les actions objects (retreive, update, destroy) qui prennent un identifieur (appelé pk) de ressource.

Il est possible de créer une route supplémentaire en utilisant un décorateur. On en utilisant @detail_route on créer une route qui prend un paramètre, et en utilisant @list_route, une route qui ne prend aucun paramètre. Ces deux décorateurs prennent entre autre un argument, la méthode HTTP correspondant à la route, soit get, soit post. (ne pas confondre la méthode HTTP qui définie comment passer les données, et l'action qui définie qu'en faire) Le nom de la vue est alors le nom de la fonction. En pratique, dans une telle fonction, on récupère un ou des objets selon un critère, on sérialise le/les objets, puis on les retourne dans une Response.

Important, la gestion des droits se fait de manière automatisée en utilisant DRYPermission. Il faut pour cela préciser à la vue d'utiliser DRYPermission. Je parlerais de la gestion des droits dans une autre partie.

Exemple type d'une vue :

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from dry_rest_permissions.generics import DRYPermissions

from sigma_core.models.monmodel import MonModel
from sigma_core.serializers.monserializer import MonSerializer

class MaViewset(viewsets.ModelViewSet):
    queryset = MonModel.objects.all()
    serializer_class = MonSerializer
    permission_classes = [IsAuthenticated, DRYPermissions]

    @detail_route(methods=['get'])
    def objet_special(self, request, pk=None):
        # [...]
        # truc type :
            data = [...]
            serializer = UnSerializer(data)
            return Response(serializer.data)
        # s'il manque des droits
            return Response("Pas possible", status=status.HTTP_403_FORBIDDEN)

Les URLs

Une fois la vue créée, il faut dire a Django de faire correspondre une url avec une vue. Encore une fois, grace à REST qui standardise le tout, le processus est simplifié. Il suffit de lui donner une Viewset et il va générer automatiquement les url correspondantes. Dans le cas d'une action monaction() particulière créée avec @detail_route ou @list_route dans un viewset "mavue", l'url correspondant est alors mavue/monaction ou mavue/?pk/monaction

Ligne à insérer dans le fichier sigma/url.py :

router = routers.DefaultRouter() # deja present au debut du fichier

from sigma_core.views.maviewset import MaViewset
router.register(r'mavue', MaViewset) # genere l'url /mavue/... avec toutes les routes définies dans la viewset

Les droits (Doc)

La gestion des droits peut se faire de moult manières. De base, django le permet avec des fonctions à définir dans le modèle. Mais elles sont souvent assez limitée et inadaptée, on préfère donc un système de droit basée sur la vue, qui inclue donc la notion d'utilisateur connecté. Cela reste cependant souvent incomplet. D'où l'utilisation de DRYPermission !

Ce framework permet de définir très facilement les permissions. Mode d'emploi : on dit à la vue d'utiliser le système de droit de DRYPermission (comme fait dans la partie vue) Puis on définit dans le modèle, un certain nombre de fonctions de permissions.

On distingue deux type de fonction de permission :

  • La permission globale, elle est systématiquement appelée avant même la création de l'objet. Elle sert simplement à dire si l'utilisateur en question a accès, de manière générale, à un type de droit, sur un modèle donné. On définit alors une fonction statique @staticmethod def has_XXX_permission(request) dans le modèle. XXX est le nom de l'action pour laquelle on veut définir la permission. Cette méthode sera automatiquement appelée par la vue lorsque la route demande l'action XXX, et la fonction d'action de la Viewset ne sera exécutée que si has_XXX_permission retourne True. La fonction est statique, puis que l'action est globale, elle ne dépend d'aucun object en particulier. On a bien sur accès à l'utilisateur via request.user mais aussi aux données de requêtes via request.data
  • La permission objet, elle, fonctionne de la même façon, mais elle est appelée lorsque l'on manipule un objet en particulier. On définit alors une méthode def has_object_XXX_permission(self, request) dans le modèle, où XXX est le nom de l'action pour laquelle on veut définir la permission. Même fonctionnement que pour la permission globale, la différence est que la fonction est cette fois ci une méthode. On a donc accès à l'objet concerné par la requête, via self.

Pour les actions globales (list, create, ou définies par @list_route), seule la permission globale correspondante sera appelée. Pour les actions objets (retrieve, destroy, update, ou définies par @detail_route), la permission globale correspondante sera appelée, et si elle est validée, alors l'objet sera crée, puis la fonction de permission objet sera appelée, et une fois que celle ci valide, la requête sera traitée.

Important, il ne peut y avoir de fonction de permission objet sans la fonction de permission globale correspondante. Autrement, DRYPermission générera une erreur. Pour éviter de devoir définir une quantité énorme de méthodes de droits pouvant potentiellement avoir la même forme, il faut savoir que DRYPermission ajoute deux types d'actions :

  • Les actions read (avec donc has_read_permission et has_object_read_permission) qui peuvent remplacer les permission globales et objets des actions list, retrieve, et toute route définie en méthode GET
  • Les actions write (avec donc has_write_permission et has_object_write_permission) qui peuvent remplacer les permission globales et objets des actions update, create, destroy et toute route définie en méthode POST.

Exemple, pour définir le droit de détruire mais pas d'update, on pourrait faire :

@staticmethod
def has_write_permission(request):
    return True

def has_object_destroy_permission(self, request):
    return True

def has_object_update_permission(self, request):
    return False

En plus des actions de base, DRY définit deux types de permission qui permette d'englober les autres pour réduire la quantité de code. Il y a les actions de type read : list, retreive, et toute route définie comme utilisant la méthode GET ; et les actions de type write : create, update, destroy et toute route définie comme utilisant la méthode POST.