Django & DRF : DRF tips & tricks
DUVAL Olivier
Posted on November 23, 2020
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
- ViewSet
- Serializer
- Conclusion et liens
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()
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)
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)
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)
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)
le self.get_paginated_response(data) forme un JSON de type
{ 'count': total_lignes,
'next': url_next, 'previous': url_previous,
'results': [objets] }
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()
# ...
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)
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)
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__'
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
},
]
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__'
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
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
},
]
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
}
# ...
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
}
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()
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
@action(detail=True)
def get_book(self, request, request, *args, **kwargs):
instance = self.get_object()
pass
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
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)
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)
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'
DRF prendra alors l'attribut uuid du modèle pour filtrer
Dossier.objects.get(uuid=valeur)
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)
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
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
# ...
Le schéma de la hiérarchie complète (générée avec Pycharm)
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__'
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")
Posted on November 23, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.