Django & DRF : DRF tips & tricks

zorky

DUVAL Olivier

Posted on November 23, 2020

Django & DRF : DRF tips & tricks

Un article sur DRF, Django Rest Framework (module pour créer des API), sur des aspects plus avancés de la librairie.

DRF présente un certain nombres de méthodes / fonctions qui peuvent être surchargées à des fins d'utilité, d'optimisations, etc, voyons ce que l'on peut en faire.

Sommaire

get_queryset

Dans le ModelViewSet, parmi les champs requis, il y a queryset qui précise la "requête" à exécuter pour les différentes opérations (GET, POST, ...) afin de retrouver les entités du modèle souhaité.

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
Enter fullscreen mode Exit fullscreen mode

Il peut être intéressant de la construire selon le contexte d'utilisation de l'API ou lorsque la queryset commence à être longue. Ainsi, selon le connecté, les entités ramenées peuvent variées, par exemple : les livres de l'auteur connecté ou tous les livres lors d'une gestion d'un administrateur.

Il suffit alors de surcharger la méthode get_queryset qu'appelle DRF, dans sa version par défaut, il renvoie la propriété habituelle définie dans le viewset, à savoir queyset

Par exemple, même si l'exemple est simple, on pourrait, si on a un rôle de gestionnaire, on renvoie tous les livres, et si, c'est un client, on ne renvoie que les livres disponibles, les règles métiers pourraient être plus complexes.

class BookViewSet(viewsets.ModelViewSet):
   # ...
   def get_queryset(self):
     if self.isAdmin():
       return Book.objects.all()
     return Book.objects.filter(enabled=True)
Enter fullscreen mode Exit fullscreen mode

ou si un auteur ne doit voir que ses livres, pour sa gestion par exemple, on peut réduire la liste de cette façon

class BookViewSet(viewsets.ModelViewSet):
   # ...
   def get_queryset(self):
     qs = Book.objects.select_related('author')

     if self.isAdmin():
       return qs.all()
     if self.isAuthor():
       return qs.filter(author__user=self.request.user)
     return qs.filter(enabled=True)
Enter fullscreen mode Exit fullscreen mode

filter_queryset

filter_queryset est utilisé par défaut sur les listes ou l'obtention d'un objet (GET) ou lors des autres opérations (DELETE / PUT), pour détecter les paramètres de la querystring "?filtre1=valeur1&filtre2=valeur2", filtre1 / filtre2 peuvent être des champs du modèle ou des filtres personnalisés, lors de l'usage des filtres FilterSet ou filterset_fields de la viewset.

En gros, lorsque vous faites un /authors/?last_name=duval, DRF appelle filter_queryset() (méthode contenue dans GenericAPIView, classe mère utilitaire, on a cet héritage de défini pour la ModelViewSet : GenericViewSet : GenericAPIView) qui se charge de filtrer sur le last_name les auteurs.

En revanche, lors de l'utilisation des @action, ce filtrage n'est pas appelé explicitement, vous perdez alors tout ce bénéfice, ce qui est dommage.

Il suffit alors de l'appeler explicitement, s'il y a des filtres dans la querystring, ils seront appliqués.

Par exemple, imaginons une action qui renvoie
la liste des dossiers du connecté : application d'un filtre sur le connecté sur la queryset par défaut de la viewset puis on applique filter_queryset sur cette dernière, s'il y a d'autres critères, ils seront appliqués :

    @action(detail=False, methods=['get'])
    def mes_dossiers(self, request):
        """
        Liste des dossiers du le connecté
        """
        connecte = self.request.user
        qs = self.get_queryset().filter(user=connecte)
        dossiers = self.filter_queryset(qs)

        serializer = self.get_serializer(dossiers, many=True)
        return Response(serializer.data)
Enter fullscreen mode Exit fullscreen mode

paginate_queryset / get_paginated_response

Comme pour filter_queryset, on peut appeler explicitement la mise en place de la pagination, avec self.paginate_queryset(qs) sur la queryset et la création du JSON de retour avec self.get_paginated_response(data), dans une @action.

Par exemple, si la pagination est demandée (le paramètre limit est dans la querystring, de la forme /api/books/?limit=5&offset=0) alors la pagination est constituée sinon la liste simple d'objets sera ramenée au client navigateur.

    @action(detail=False)
    def get_stats(self, request):
        '''
        Obtient les stats ....
        '''
        qs = self.filter_queryset(qs)
        r = Modele.objects.by_days(qs)

        page = self.paginate_queryset(r)
        if page is not None: # on remonte la pagination
            serializer = ModeleSerializer(page, many=True)
            r = self.get_paginated_response(serializer.data)
            return r

        # pas de pagination demandée, on remonte une liste d'objets
        s = ModeleSerializer(r, many=True)
        return Response(s.data)
Enter fullscreen mode Exit fullscreen mode

le self.get_paginated_response(data) forme un JSON de type

{ 'count': total_lignes, 
  'next': url_next, 'previous': url_previous, 
  'results': [objets] }
Enter fullscreen mode Exit fullscreen mode

self.paginate_queryset() et self.get_paginated_response() sont contenu dans la pagination utilisée dans la 2ère partie Django, à savoir la classe LimitOffsetPagination, elles peuvent être surchargées à leur tour pour personnaliser la pagination.

perform_create / perform_update / perform_destroy

Ces 3 fonctions appartiennent aux "Mixins" DRF (incluses dans les viewsets proposées ModelViewSet ou CreateAPIView ou ...), elles sont chargées de la création / mise à jour ou suppression effective d'un objet.

DRF appelle ces fonctions dans les create() / update() / destroy() des mixins, à l'intérieur de celles-ci, par exemple pour le create() (dans CreateModelMixin), perform_create() est chargée du save() de l'object et donc de sa création.

class CreateModelMixin:
    # ...
    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer) # ** appel **
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def perform_create(self, serializer):
        serializer.save()
    # ...
Enter fullscreen mode Exit fullscreen mode

On peut surcharger ses fonctions appelées à l'intérieur afin d'y apporter des comportements supplémentaires au save() ou effectuer des vérifications avant le save().

Ce pattern se nomme le pattern "Hollywood" : "Ne m'appelez pas, je vous appelerai" :) notamment utilisé pour l'IoC mais aussi pour créer des hooks ou autre usage (notamment en partie dans le design pattern Strategy), comme dans DRF, de modifier un comportement déjà implémenter, pattern que j'aime à utiliser car il donne la possibilité d'étendre un comportement sans modification de code.

Dans le cadre d'une API, cela peut être utile pour compléter le modèle à sauvegarder ou exécuter des actions de traitement, par exemple ajouter automatiquement l'utilisateur ayant fait l'action, ici dans le save() du modèle

    def perform_create(self, serializer):
        instance = serializer.save(updated_by=self.request.user.individu)
Enter fullscreen mode Exit fullscreen mode

ou après une mise à jour, appeler une fonction pour un traitement spécial, ici _create_or_update_destinataires(instance)

   def perform_update(self, serializer):
       """
       Hook à la modification d'une instance : creation ou modification des 
       destinataires
      """
      instance = serializer.save()
      self._create_or_update_destinataires(instance)
Enter fullscreen mode Exit fullscreen mode

get_serializer_class

La fonction get_serializer_class() (également dans GenericAPIView) surchargée permet de préciser un autre Serializer dynamiquement selon le contexte.

En effet, l'on souhaite parfois des versions simplifiées des données ramenées, par exemple lorsque l'on liste ou qu'on édite un objet, toutes les informations ne sont pas tout le temps nécessaires, surtout s'il commence à y avoir beaucoup de données imbriquées (nested serializers), selon son besoin.

Le serializer détermine les données à ramener (en JSON), cela induit aussi les requêtes à exécuter pour les chercher, cela peut être une forme aussi d'optimisation.

Par exemple, dans la version 3.2 de notre tutoriel, le serializer livre BookSerializer par défaut ressemble à ceci, avec l'auteur attaché

class AuthorSerializer(serializers.ModelSerializer):
    books_obj = BookSimpleSerializer(source='books', many=True, read_only=True)

    class Meta:
        model = Author
        fields = '__all__'

class BookSerializer(serializers.ModelSerializer):
    author_obj = AuthorSerializer(source='author', read_only=True)

    class Meta:
        model = Book
        fields = '__all__'
Enter fullscreen mode Exit fullscreen mode

ce qui pour la liste des livres (/books/) génère le JSON suivant, la liste books_objs est de trop pour author_obj, pas de nécessité ici

[
    {
        "id": 1,
        "author_obj": {
            "id": 14,
            "books_obj": [
                {
                    "id": 1,
                    "dt_created": "2020-02-22T15:57:26.934127",
                    "dt_updated": "2020-04-11T18:55:09.352309",
                    "name": "Django primer : ultimate guide",
                    "nb_pages": 150,
                    "enabled": true,
                    "author": 14
                }
            ],
            "dt_created": "2020-04-11T18:55:06.977351",
            "dt_updated": "2020-04-18T17:33:29.349090",
            "first_name": "Aurélie",
            "last_name": "Dudu 2"
        },
        "dt_created": "2020-02-22T15:57:26.934127",
        "dt_updated": "2020-04-11T18:55:09.352309",
        "name": "Django primer : ultimate guide",
        "nb_pages": 150,
        "enabled": true,
        "author": 14
    },
]
Enter fullscreen mode Exit fullscreen mode

A la rigueur, ce serializer peut être le bienvenu pour afficher un livre précis, avec son auteur pour ensuite afficher liste des livres de cet auteur, pour une liste de livres, on ferait certainement autrement.

Limitons le livre à plus simple : juste son auteur attaché, via les serializers suivant

class AuthorSimpleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = '__all__'

class BookAuthorSimpleSerializer(serializers.ModelSerializer):
    author_obj = AuthorSimpleSerializer(source='author', read_only=True)

    class Meta:
        model = Book
        fields = '__all__'
Enter fullscreen mode Exit fullscreen mode

qui sera utilisé uniquement lorsque l'action de listage est appelée.

DRF permet de détecter quelle action est appelée (pour faire court, dans le CRUD) via le self.action qui propose pour le CRUD les valeurs suivantes (des "string") (NB : si une @action est utilisée, le self.action contiendra le nom de cette action) :

  • GET /authors/1/ : 'retrieve'
  • PUT /authors/1/ : 'update'
  • POST /authors/ : 'create'
  • PATCH /authors/1/ : 'partial_update'
  • GET /books/ : 'list'

on va utiliser cette possibilité dans get_serializer_class() et selon le type d'action renvoyer BookAuthorSimpleSerializer (list) ou la classe par défaut définie dans la viewset (serializer_class = BookSerializer) pour les autres actions, ce qui donnera :

class BookViewSet(viewsets.ModelViewSet):
    serializer_class = BookSerializer
    permission_classes = [AllowAny]
    filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter,)
    filterset_class = BookFilter

    def get_serializer_class(self):
        if self.action == 'list':
            return BookAuthorSimpleSerializer

        return self.serializer_class
Enter fullscreen mode Exit fullscreen mode

Lors d'un appel à /books/, on aura maintenant ce JSON retourné, un peu plus léger

[
    {
        "id": 1,
        "author_obj": {
            "id": 14,
            "dt_created": "2020-04-11T18:55:06.977351",
            "dt_updated": "2020-04-18T17:33:29.349090",
            "first_name": "Aurélie",
            "last_name": "Dudu 2"
        },
        "dt_created": "2020-02-22T15:57:26.934127",
        "dt_updated": "2020-04-11T18:55:09.352309",
        "name": "Django primer : ultimate guide",
        "nb_pages": 150,
        "enabled": true,
        "author": 14
    },
]
Enter fullscreen mode Exit fullscreen mode

get_serializer_context

get_serializer_context() est utilisé pour passer des valeurs au serializer de la viewset, par défaut, il est appelé dans le get_serializer (GenericAPIView) et envoie la request, le format et la vue / view

class GenericAPIView(views.APIView):
    # ...
    def get_serializer(self, *args, **kwargs):
        """
        Return the serializer instance that should be used for validating and
        deserializing input, and for serializing output.
        """
        serializer_class = self.get_serializer_class()
        kwargs['context'] = self.get_serializer_context()
        return serializer_class(*args, **kwargs)

    def get_serializer_context(self):
        """
        Extra context provided to the serializer class.
        """
        return {
            'request': self.request,
            'format': self.format_kwarg,
            'view': self
        }   
   # ...
Enter fullscreen mode Exit fullscreen mode

Cela peut être utile de lui envoyer d'autres valeurs dont on aurait besoin dans le serializer lors d'un calcul

Par exemple, dans notre vue / viewset, on surcharge la fonction get_serializer_context(), et on lui passe une année passée en paramètre dans l'API ou celle par défaut

class MaViewSet(ModelViewSet):
    # ...
    def get_serializer_context(self):
        annee = self.request.data['annee'] if 'annee' in self.request.data else get_annee()
        return {
            'request': self.request,
            'format': self.format_kwarg,
            'view': self,
            'annee': annee
        }
Enter fullscreen mode Exit fullscreen mode

et dans le serializer, on peut lire cette valeur via self.context

class MonSerializer(AnnuaireBaseSerializer):    
    propriete_obj = serializers.SerializerMethodField(read_only=True)

    def get_propriete_obj (self, obj):
        """
        on peut dès lors obtenir l'attribut "annee" avec sa valeur et potentiellement faire une requête liée à cette année, ici, le nombre de membres pour cette année
        """
        annee = '2020'
        if 'annee' in self.context:
           annee = self.context['annee']

        membres = Membres.objects.filter(annee=annee)
        return membres.count()
Enter fullscreen mode Exit fullscreen mode

Pratique !

action

Les ModelViewSet permettent un CRUD (post, get, put, delete) sur un modèle, il peut intéressant de spécialisé une API à l'intérieur d'un endpoint, par exemple get_books_online() qui permettrait d'obtenir uniquement les books en ligne, c'est pour l'exemple.

Il existe 2 type d'actions qui seront représentées par l'annotation @action : celle pour renvoyer une liste ou un traitement, celle qui se base sur un objet particulier pour lequel l'id sera mis en querystring et trouvé grâce à self.get_object(), tout se joue au niveau du mot clé detail à False ou True pour le second cas.

  @action(detail=False)
  def get_books_online(self, request):
     pass
Enter fullscreen mode Exit fullscreen mode
  @action(detail=True)
  def get_book(self, request, request, *args, **kwargs):
    instance = self.get_object()
    pass
Enter fullscreen mode Exit fullscreen mode

lookup_field

Le lookup_field est la clé par défaut de recherche sur un modèle, elle est fixée habituellement à "pk" (l'identifiant du modèle qui est défini automatiquement, la plupart du temps cela représente l'id, clé primaire auto incrémentée de la table).

Le lookup_field va être utilisé par DRF lors de l'appel de la fonction get_object() (GenericAPIView) pour retrouver l'entité précise.

Code DRF du get_object() réduit au code utile, le filter_kwargs sera utilisé pour un get, de la façon suivante Model.objects.get(pk=valeur)

class GenericAPIView(views.APIView):
    # ...
    def get_object(self):
        """
        Returns the object the view is displaying.        
        """
        queryset = self.filter_queryset(self.get_queryset())

        # Perform the lookup filtering.
        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field

        filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
        obj = get_object_or_404(queryset, **filter_kwargs)

        # May raise a permission denied
        self.check_object_permissions(self.request, obj)

        return obj
Enter fullscreen mode Exit fullscreen mode

lookup_field peut être surchargé pour changer la clé de recherche d'un objet.

Par exemple, imaginons un modèle de Dossier, sa clé primaire sera par défaut "id" et la recherche d'un dossier de type /dossiers/2/ s'effectuera avec un Dossier.objects.get(pk=2)

class Dossier(TimeStampedModel):    
    reference = models.CharField(max_length=150, null=True)    
Enter fullscreen mode Exit fullscreen mode

Imaginons maintenant que nous voulons utiliser plutôt des UUID, pour sécuriser l'URL (à la place de /dossiers/2/ on souhaite /dossiers/7778c552-73fc-4bc4-8bf9-5a2f6f7b7f47/, le connecté ne peut ainsi pas itérer sur les ID), tout en conservant l'auto-incrément id pour faciliter les requêtes manuelles.

Le modèle sera enrichi d'un uuid

class Dossier(TimeStampedModel):    
    reference = models.CharField(max_length=150, null=True)    
    uuid = models.UUIDField(default=uuid.uuid4)
Enter fullscreen mode Exit fullscreen mode

Dans notre viewset, il suffira de dire que la clé est maintenant "uuid"

class DossiersViewSet(viewsets.ModelViewSet):
    # surcharge de la clé de recherche pour le get ou get_object()
    lookup_field = 'uuid'
Enter fullscreen mode Exit fullscreen mode

DRF prendra alors l'attribut uuid du modèle pour filtrer

Dossier.objects.get(uuid=valeur)
Enter fullscreen mode Exit fullscreen mode

Nota Bene : Django permet d'utiliser directement des uuid en clé primaire, il suffit de surcharger id dans la définition du modèle en lui précisant primary_key=True

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Enter fullscreen mode Exit fullscreen mode

Serializers : create / update

Le ModelSerializer qui est utilisé à 80 % contient 2 méthodes qui peuvent être surchargées afin d'apporter un changement de comportement sur l'enregistrement d'un modèle.

En effet, nous avons la hiérarchie suivante

BookSerializer <-- ModelSerializer create() / update() <-- Serializer <-- BaseSerializer save() (appel de create() ou update()) <-- Field

Le save() de BaseSerializer appelle create() ou update() de ModelSerialier selon le mode d'écriture du modèle en cours (en création ou en modification), bon pattern ! Hollywood ;)

Pseudo code du BaseSerializer et du ModelSerializer qui implémente de create() et l'update() que demande BaseSerializer

class BaseSerializer(Field):
    def update(self, instance, validated_data):
        raise NotImplementedError('`update()` must be implemented.')

    def create(self, validated_data):
        raise NotImplementedError('`create()` must be implemented.')

    def save(self, **kwargs):
        # ...
        if self.instance is not None:
            self.instance = self.update(self.instance, validated_data)
        else:
            self.instance = self.create(validated_data)

        return self.instance
Enter fullscreen mode Exit fullscreen mode
class ModelSerializer(Serializer):
    # ... instance = model
    def create(self, validated_data):
        instance = ModelClass._default_manager.create(**validated_data)
        return instance
    def update(self, instance, validated_data):
        instance.save() # instance = model
        return instance
    # ...
Enter fullscreen mode Exit fullscreen mode

Le schéma de la hiérarchie complète (générée avec Pycharm)

Alt Text

Il faut savoir que DRF ne sait pas manipuler en création ou mise à jour les relations Many-to-Many via les serializers voire les nested serializers qui ont un through dans la définition du modèle ManyToManyField, autrement dit, si vous avez un many to many à modifier ou à créer, il ne le fera pas et lèvera une exception, il va falloir les effectuer autrement et c'est ça que la surcharge du create() ou update() intervient.

Par exemple, imaginons une salle ayant plusieurs équipements possibles (wifi, écran, ...), en UML, cela se représentera de la façon suivante : Room * ---- * Equipment, le serializer Room mettra en read_only (obligatoire) le serializer embarqué pour les équipements.

On devra alors surcharger create() et update() pour prendre en compte les équipements pour les traiter comme un champ particulier, et en les sortant des data, comme fait dans le code suivant qui a pour but d'avoir des fonctions update sur les équipements (ajout ou suppression) d'une salle, une sorte de hook.

class Room(models.Model):
  equipments = models.ManyToManyField('Equipment', through='EquipmentToRoom', related_name='rooms')

class RoomSerializer(serializers.ModelSerializer):
      # input equipements pour création ou màj
    equipments_ids = serializers.PrimaryKeyRelatedField(queryset=Equipment.objects.all(), write_only=True, many=True)
    # output des equipements
    equipments_obj = EquipmentSerializer(source='equipments', many=True, read_only=True)

    def update(self, instance, validated_data):
       """ prise en compte équipements salle pour màj """
        raise_errors_on_nested_writes('create', self, validated_data)

        equipements_ids = validated_data.pop('equipments_ids', None)
        instance = super().update(instance, validated_data)
        instance.update_equipements(instance, equipements_ids)

        return instance

    def create(self, validated_data):
        """ prise en compte équipements pour création salle """
        equipements_ids = validated_data.pop('equipments_ids', None)
        instance = super().create(validated_data)
        instance.update_equipements(instance, equipements_ids)

        return instance


      class Meta:
        model = Room
        fields = '__all__'
Enter fullscreen mode Exit fullscreen mode

Conclusion

Les attributs / fonctions de GenericView qui peuvent être surchargés : Basics settings et d'autres que vous retrouverez un peu plus loin dans la page ("Other methods")

Alt Text

💖 💪 🙅 🚩
zorky
DUVAL Olivier

Posted on November 23, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Django & DRF : DRF tips & tricks
django Django & DRF : DRF tips & tricks

November 23, 2020