Django & DRF Flexibility

Rishi Banerjee
Django Unleashed
Published in
14 min readApr 19, 2024

“Express.js is so damn flexible” — Startup Intern

Sure, it is, but did you know? What takes a week in Express.js, Django knocks out in a single afternoon!

With Django Ninja showing so much promise, this blog might not be that relevant for senior Django engineers. This is a intro level blog to Django & Django Rest Framework classes and how to modify sub classes to achieve custom behaviour.

Django is often pegged as being somewhat rigid by some developers.

Its so hard to customize things Rishi!! Whenever I try to do something different, Django seems to suffocate me :(

This view, however, doesn’t quite capture the entire picture. Django’s design philosophy — “batteries included” — does equip developers with a robust suite of ready-to-use tools, but it also provides ample room for customization. If someones says “Django cannot be customized”, it surely sounds like a skill issue to me

In this post, we’ll explore the inherent flexibility of Django and its companion, Django Rest Framework (DRF), by delving into how overriding superclass methods can significantly tailor and enhance your applications.

Before we dive right into Django classes, lets just first talk about Python.

When we talk about method overriding in Python, we’re tapping into one of the fundamental principles of object-oriented programming (OOP): polymorphism. This concept allows us to alter or extend the default behavior of a program at a fundamental level. I had such a hard time findings its usecase 10 years ago. Why would I override the bark function of class Animal? It was a good example but I have a better one.

Imagine you buy a standard car model from a dealership. This car comes with a default set of features — engine, transmission, wheels, and so on. However, as the owner, you decide to customize this car. You might swap out the engine for a more powerful one, change the wheels for better performance, or even tweak the interior to better suit your taste.

In programming, particularly in Python, overriding methods works similarly. You start with a standard, or Base class, which provides basic functionality. But when you need specific functionality not provided by the base class, you can create a subclass and modify (override) the methods to meet your requirements, just like customizing your car.

Python Example: Enhancing a Notification System

Let’s consider a notification system in a software application. The base class provides a simple way to send notifications, but we might need to customize the way notifications are sent depending on their type (e.g., email, SMS, push notification).

Base Class — Notification

The base class defines a simple method to send a notification, which includes just a basic message:

class Notification:
def send(self, message):
print(f"Sending notification: {message}")

Subclass — EmailNotification

We want our email notifications to include a subject line and to format the message differently. We override the `send` method in a subclass to accommodate these additional details:

class EmailNotification(Notification):
def send(self, message, subject):
formatted_message = f"Subject: {subject}\n{message}"
print(f"Sending email: {formatted_message}")

Subclass — SMSNotification

For SMS notifications, we might want to ensure the message is within a certain character limit and does not include non-text elements like images or emojis:

class SMSNotification(Notification):
def send(self, message):
if len(message) > 160:
message = message[:157] + '...'
print(f"Sending SMS: {message}")

Using the Subclasses

With these subclasses, we can now tailor our notification system to handle different types of notifications appropriately, much like customizing different aspects of a car:

email = EmailNotification()
email.send("Hello, your order has been shipped.", "Order Update")

sms = SMSNotification()
sms.send("Your appointment is coming up soon!")

Just as with car modifications, method overriding lets developers fine-tune their software’s functionality to deliver a more personalized and effective experience. This approach is a cornerstone of software development.

The Essence of Django’s Design

Django’s architecture encourages swift development with a clean, pragmatic approach. It’s not about enforcing a single way to solve problems but about offering a solid foundation that’s designed to be built upon.

Using Django with OOP: Overriding Methods

Object-oriented programming (OOP) lies at the heart of extending Django’s capabilities. Overriding methods, a core concept in OOP, is readily supported and can be a potent way to modify or extend Django’s built-in behavior.

Customizing Model Save Method

Take a simple blog post model in Django. By overriding the save method, developers can inject custom behavior into the model’s save operations, like ensuring all titles are saved in uppercase:

from django.db import models

class BlogPost(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
publish_date = models.DateTimeField(auto_now_add=True)

def save(self, *args, **kwargs):
self.title = self.title.upper()
super().save(*args, **kwargs)

Here, the save method is an overridden version of the save method from Django's models.Model class.

A peek into the Django Model class in /django/db/models/base.py

Tailoring Form Validation

Django forms also benefit from method overriding. Customizing validation logic by overriding methods like clean_email allows for specific checks, which can be crucial for maintaining data integrity and user experience:

from django import forms
from django.core.exceptions import ValidationError

class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()

def clean_email(self):
email = self.cleaned_data['email']
if "example.com" in email:
raise ValidationError("Registration using 'example.com' is not allowed.")
return email

These were some basic override that you can do in Django, lets go to DRF, something modern django apps heavily use to create great API’s.

Customizing DRF

Building on Django, DRF enhances API development with similar extensibility. Overriding methods within DRF can tailor API behavior to fit even the most specific requirements.

Enhancing Serializer Outputs

In Django Rest Framework (DRF), serializers play a crucial role in how data is structured and presented to the user. They manage the conversion of complex data types, like Django models, into JSON for API responses and handle incoming data for storage in the database. By overriding methods like to_representation, developers gain significant control over this process, allowing for precise manipulation of the serialized data.

The Necessity of Overriding to_representation

While DRF is highly efficient and handles many tasks automatically, real-world applications frequently require specific adjustments that the default serializers don’t support out of the box. This necessity often leads to overriding methods like to_representation to ensure the data conforms to specific needs or client requirements.

For example, you might need to add additional fields that depend on complex logic or aggregate data from multiple sources. Similarly, adjusting the formatting of certain fields, such as dates or monetary values, to meet localization standards is another common requirement.

from rest_framework import serializers
from django.contrib.auth.models import User

class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email']

def to_representation(self, instance):
"""Modify the output format of the serializer."""
ret = super().to_representation(instance)
# Add a custom field based on complex logic
ret['is_vip'] = instance.groups.filter(name='VIP').exists()
# Format the email field to be lowercase
ret['email'] = instance.email.lower()
return ret

Utilizing Different Serializers Based on Context

Overriding get_serializer_class allows for dynamic selection of serializers based on the action being executed, providing enhanced flexibility and control over how data is presented. Typically, in a list view — used to display multiple items on a client application — you’d want to streamline the JSON response to improve performance and load times.

Conversely, in a detail view, where a single item’s comprehensive details are necessary, a more detailed JSON structure is preferable. This method enables tailored data representation to suit different use cases efficiently.

from rest_framework import viewsets
from .models import User
from .serializers import UserDetailSerializer, UserListSerializer

class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()

def get_serializer_class(self):
# we use the UserListSerializer which contains
# lesser details than the UserDetailSerializer
if self.action == 'list':
return UserListSerializer
else:
return UserDetailSerializer

Adjusting the Serialization Process

Customizing the serialization process can help you manipulate how data is saved or updated. Override create and update methods in serializers to include additional logic during these processes:

from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'

def create(self, validated_data):
# Custom logic before creating an object
user = User.objects.create(**validated_data)
send_welcome_email(user.email) # For example, sending a welcome email
return user

def update(self, instance, validated_data):
# Custom logic before updating an object
instance.name = validated_data.get('name', instance.name)
instance.save()
notify_user(instance.email) # Notify user about the update
return instance

Many of the send_welcom_email kind of features should achieved by signals with a mix of task queues in production, or use Django Lifecycle Hooks which we use way too much at our company

Customizing Pagination Behavior

Sometimes, the default pagination may not suit the specific needs of your API. You can override the get_paginated_response method in a custom paginator to change how pagination metadata is formatted or what additional data is included. Also in high-traffic applications, you might want users to have the ability to define their pagination sizes within certain limits:

from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response

class CustomPagination(PageNumberPagination):
def get_page_size(self, request):
default_size = 10
max_size = 100
try:
return max(
min(int(request.query_params.get('page_size', default_size)), max_size),
1
)
except (TypeError, ValueError):
return default_size

def get_paginated_response(self, data):
return Response({
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'page_size': self.get_page_size(self.request),
'data': data
})

This class inherits from DRF’s PageNumberPagination class, which is a pagination style that displays page numbers for navigating through the results. By subclassing PageNumberPagination, we can customize its behavior.

get_page_size method: This method determines the number of items to include on each page of the paginated response. If the client specifies a page_size query parameter in the request, it tries to convert it to an integer. If the conversion fails, it returns a default size of 10. Additionally, it ensures that the page size falls within a specified range (max_size).

The get_paginated_response method generates the paginated response data. It takes the data as input, representing the queryset or data to be paginated. It utilizes DRF’s Response class to construct the HTTP response. It includes links for navigating to the next and previous pages (‘next’ and ‘previous’), obtained using the get_next_link() and get_previous_link() methods provided by PageNumberPagination. The page size is obtained by calling the get_page_size method with self.request. It includes the actual data for the current page.

Altering Permission Checks

The get_permissions method is a part of DRF's view classes. It's called during the request handling process to determine the permissions that should be applied to the current request. By default, this method fetches the permission classes defined in the view's permission_classes attribute. However, overriding this method allows for more nuanced and conditional permission logic.

When to Override get_permissions ?

Overriding get_permissions becomes essential when the access rules for your API need to change dynamically based on the context of the request. Here are a few scenarios where you might need to implement this method:

  • Different Permissions for Different Actions: You might want to allow read-only access to unauthenticated users but restrict write permissions to authenticated users.
  • Role-Based Access Control (RBAC): Access might depend on the user’s role within the system (e.g., admins can delete posts, but regular users cannot).
  • Object-Level Permissions: Conditions where access depends on properties of the object being accessed or its relationship to the user.

Let’s consider an API for a blogging platform where the permissions vary significantly between different actions:

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser
from .models import Post
from .serializers import PostSerializer

class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer

def get_permissions(self):
"""
Instantiates and returns the list of permissions that this view requires.
"""
if self.action == 'list':
permission_classes = [AllowAny]
elif self.action in ['create', 'update', 'partial_update']:
permission_classes = [IsAuthenticated]
elif self.action == 'destroy':
permission_classes = [IsAdminUser]
else:
permission_classes = [AllowAny] # Default Permission
return [permission() for permission in permission_classes]

Dynamic Querysets

In DRF, ViewSets generally require you to specify a static queryset. However, in multi-tenant applications where data access needs to be scoped to individual tenants, you must dynamically adjust the queryset based on the tenant context. This is particularly important to ensure that users only access data that belongs to their respective tenants. Here’s how you can modify the get_queryset method in your ViewSet to accommodate a multi-tenancy setup by using a tenant ID from the request headers.

from rest_framework import viewsets
from django.db.models import Q
from .models import Item
from .serializers import ItemSerializer

class ItemViewSet(viewsets.ModelViewSet):
"""
A simple ViewSet for viewing and editing items scoped by tenant ID.
"""
serializer_class = ItemSerializer

def get_queryset(self):
"""
Dynamically return items based on the tenant ID specified in the request header.
"""
tenant_id = self.request.headers.get('X-Tenant-ID')
if not tenant_id:
# Handle the case where no tenant ID is provided, possibly raise an exception
raise ValueError("Tenant ID is required.")

if self.request.user.is_staff:
# Staff users can view items across all tenants, optionally filter by tenant
queryset = Item.objects.all()
if 'all_tenants' not in self.request.query_params:
queryset = queryset.filter(tenant_id=tenant_id)
else:
# Regular users can only view items within their tenant
queryset = Item.objects.filter(tenant_id=tenant_id)

# Further filter for public items if needed
if 'public' in self.request.query_params:
queryset = queryset.filter(public=True)

return queryset

By customizing the get_queryset method as shown, you can maintain strict boundaries between tenant data, while providing the necessary flexibility to accommodate various user roles and filtering needs within your application. There are many more better ways of doing tenant scoping, this was just used as an example.

Advanced Queryset Manipulations

Imagine an e-commerce platform that needs to serve product information via an API. The requirements include filtering products by multiple criteria (such as category, tags, price range), sorting, and applying role-based access control to ensure that only authorized users can view certain products.

from django.db.models import Q
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from .models import Product
from .serializers import ProductSerializer

class ProductViewSet(viewsets.ModelViewSet):
"""
A ViewSet for viewing and editing product entries with complex filtering and access control.
"""
serializer_class = ProductSerializer

def get_permissions(self):
"""
Assign permissions based on user role and action.
"""
if self.action in ['list', 'retrieve']:
permission_classes = [IsAuthenticated]
else:
permission_classes = [IsAdminUser]
return [permission() for permission in permission_classes]

def get_queryset(self):
"""
Dynamically filter products based on multiple criteria and enforce role-based restrictions.
"""
queryset = super().get_queryset()
username = self.request.user.username

# One should use something like django-filters ideally
# for filtration purposes
category = self.request.query_params.get('category')
tags = self.request.query_params.getlist('tags')
min_price = self.request.query_params.get('min_price')
max_price = self.request.query_params.get('max_price')
sort_by = self.request.query_params.get('sort', 'name') # Default sorting by name

# Basic role-based filtering
if not self.request.user.is_staff:
queryset = queryset.filter(is_public=True)

# Dynamic filtering based on URL parameters
if category:
queryset = queryset.filter(category__name=category)
if tags:
queryset = queryset.filter(tags__name__in=tags).distinct()
if min_price and max_price:
queryset = queryset.filter(price__gte=min_price, price__lte=max_price)

# Sort the queryset based on a parameter
queryset = queryset.order_by(sort_by)

return queryset

This example really shows off how tweaking methods like get_queryset and get_permissions in DRF can turn you into ninja. These aren't just fancy tricks; they're essential tools for anyone looking to build APIs that don't just serve data but serve it smartly while coding as less as possible.

As commented before extracting query params, checkout Django Filters.

By the way do you see a pattern?

In Django Rest Framework (DRF), when we define a method starting with get_, it essentially overrides a method from the superclass. This inheritance pattern allows us to customize the behavior of DRF components to better suit our application's needs.

Similarly, when working with viewsets in DRF, methods like get_queryset, get_serializer_class, and get_permissions are commonly overridden to tailor the behavior of the viewset according to specific requirements. These methods allow us to customize queryset retrieval, serializer selection, and permission checks, respectively.

Dynamic Field Selection in Viewsets

One of the challenges in designing performative APIs is managing the payload size, especially when dealing with large datasets or mobile clients where bandwidth and speed are concerns.

GraphQL fundamentally addresses the issue of dynamic field selection, among other concerns related to data fetching in web applications.

DRF offers flexible mechanisms to customize responses, and one such technique is dynamic field selection. This approach allows clients to specify exactly which fields they want to receive in the response, thus minimizing the payload and improving the response times.

The example below demonstrates how to implement dynamic field selection by overriding the get_serializer_class method in a DRF viewset. This method modifies the serializer fields dynamically based on the 'fields' parameter provided in the query string of the request.

from rest_framework import viewsets
from rest_framework.serializers import ModelSerializer
from django.db.models import Field


class DynamicFieldsModelSerializer(ModelSerializer):
def __init__(self, *args, **fields, **kwargs):
# Don't pass the 'fields' arg up to the superclass
super().__init__(*args, **kwargs)

if 'request' in self.context:
fields = self.context['request'].query_params.get('fields')
if fields:
fields = fields.split(',')
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)

class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = DynamicFieldsModelSerializer

def get_serializer_context(self):
"""Extend serializer context with request data."""
context = super().get_serializer_context()
return context

In the DynamicFieldsModelSerializer, the constructor (__init__) is modified to check for a 'fields' parameter in the request. If present, the serializer adjusts its fields accordingly:

  • Extract fields from request: The fields specified by the client are extracted from the query parameter.
  • Adjust serializer fields: It then compares these fields against the serializer’s existing fields. Fields not requested by the client are removed, ensuring that only the requested data is included in the serialized output.

Advanced Exception Handling in Django REST Framework

Properly managing exceptions in your API isn’t just about catching errors; it’s about enhancing the reliability of your application and improving the user experience by providing clear and actionable error feedback. In Django REST Framework (DRF), customizing how your API handles exceptions allows you to tailor error responses to the needs of your client applications and ensure that any issues are adequately logged or reported. Overriding the handle_exception method in your views can provide granular control over these processes.

Consider an API that serves as a critical part of your service architecture, perhaps processing transactions or user data. In such cases, simply returning generic error messages isn’t sufficient. You need detailed insights into errors for debugging, while users require specific feedback that can guide their next actions. Customizing exception handling can address both these needs effectively.

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.exceptions import APIException
import logging

# Configure your logger
logger = logging.getLogger(__name__)

class CustomAPIView(APIView):
def handle_exception(self, exc):
"""
Handle any exception that occurs, with special handling for APIExceptions.
"""
if isinstance(exc, APIException):
# Log the error with detailed information for internal tracking
logger.error(f"API Exception occurred: {exc.detail}", exc_info=True)

# Optionally, integrate with an external error monitoring service
# monitor.send(exc)

# Modify the API's error response to be more informative for the client
response = super().handle_exception(exc)
custom_response_data = {
'error': 'An unexpected error occurred',
'message': str(exc.detail) if isinstance(exc, APIException) else 'Internal server error',
'status_code': response.status_code
}
response.data = custom_response_data
return response

Key components:

  • Integration with Monitoring Tools: While the example doesn’t directly implement it, there’s a placeholder comment suggesting integration with an external monitoring service like Sentry or New Relic. This can help in aggregating, notifying, and analyzing errors.
  • User-Friendly Error Responses: The response structure is modified to include more user-friendly error messages, which can help clients understand what went wrong beyond just a status code.

Benefits of Custom Exception Handling:

  • Better User Experience: Clear, descriptive error messages help users understand problems and reduce frustration.
  • Compliance and Security: Ensuring that error messages do not expose sensitive information or system details, maintaining security and compliance.

These all were but a few examples to demonstrate the extensibility and flexibility of Django & DRF and as we’ve seen, Django and Django Rest Framework aren’t just stiff suits; they’re more like a Swiss Army knife for web developers — versatile and ready to be customized to fit the unique curves of your project.

Now, I turn the keyboard over to you! Let me know in the comments:

  • What’s the most unusual or creative way you’ve customized Django or DRF?
  • Are there any topics or tutorials you’d like to see more of on blogs like this?
  • Any fun stories about your experiences with Django or coding in general?

I’m all ears — well, all screens — and looking forward to your tales and requests. Happy coding, and remember, there’s no such thing as too many plugins… unless your app says otherwise!

You can check more about me at banerjeerishi.com, I am still working on it, but few things surely work, hopefully. (P.S This was yet another shameless self promotion)

--

--