ViewSets and Routers

Module 18: Python Backend - Django

Streamlining API Development

So far, we've learned about serializers for data transformation and various types of views for handling HTTP requests. Now we'll explore ViewSets and Routers, which further simplify API development by abstracting common patterns and reducing boilerplate code.

ViewSets and Routers represent the highest level of abstraction in Django REST Framework, combining multiple related views into a single class and automatically generating URL patterns.

Analogy: The Smart Home System

Think of building an API as constructing a smart home:

  • Function-based views are like manually controlling each device with its own remote - explicit but repetitive
  • Class-based views are like having a universal remote for each room - better organized but still requiring manual setup for each room
  • ViewSets are like a central smart home hub that automatically controls all devices using consistent patterns - "I want to turn on lights" works the same in every room
  • Routers are like the voice command system that knows which commands to route to which devices without you having to specify the details

Just as a smart home system reduces the cognitive load of managing multiple devices, ViewSets and Routers reduce the mental overhead of creating and maintaining consistent RESTful APIs.

Understanding ViewSets

A ViewSet is a class that combines the logic for multiple related views in a single class. Instead of defining separate views for listing, retrieving, creating, updating, and deleting resources, a ViewSet handles all these operations.

graph TD A[Generic API View] --> B[ViewSet] B --- C[ModelViewSet] B --- D[ReadOnlyModelViewSet] C --- E[list] C --- F[create] C --- G[retrieve] C --- H[update] C --- I[partial_update] C --- J[destroy]

ViewSets map standard HTTP methods to actions:

HTTP Method ViewSet Action URL Pattern Description
GET list /books/ Get a list of all books
POST create /books/ Create a new book
GET retrieve /books/{id}/ Get a specific book
PUT update /books/{id}/ Update a specific book (complete)
PATCH partial_update /books/{id}/ Update a specific book (partial)
DELETE destroy /books/{id}/ Delete a specific book

Creating Your First ViewSet

Let's create a simple ViewSet for our Book model:


# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing book instances.
    """
    queryset = Book.objects.all()
    serializer_class = BookSerializer
            

That's it! With just these few lines, we've created a complete API that handles all CRUD operations. The ModelViewSet class automatically provides:

Read-Only ViewSet

If you need a read-only API, you can use ReadOnlyModelViewSet:


# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer

class BookViewSet(viewsets.ReadOnlyModelViewSet):
    """
    A viewset for viewing book instances.
    """
    queryset = Book.objects.all()
    serializer_class = BookSerializer
            

This provides only the list and retrieve actions, omitting the create, update, and delete functionality.

ViewSet Actions and Methods

ViewSets provide standard actions, but you can also add custom actions:


from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer, ReviewSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    
    @action(detail=True, methods=['post'])
    def mark_bestseller(self, request, pk=None):
        book = self.get_object()
        book.is_bestseller = True
        book.save()
        serializer = self.get_serializer(book)
        return Response(serializer.data)
    
    @action(detail=True, methods=['get'])
    def reviews(self, request, pk=None):
        book = self.get_object()
        reviews = book.reviews.all()
        
        page = self.paginate_queryset(reviews)
        if page is not None:
            serializer = ReviewSerializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        
        serializer = ReviewSerializer(reviews, many=True)
        return Response(serializer.data)
    
    @action(detail=False, methods=['get'])
    def bestsellers(self, request):
        bestsellers = self.get_queryset().filter(is_bestseller=True)
        
        page = self.paginate_queryset(bestsellers)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        
        serializer = self.get_serializer(bestsellers, many=True)
        return Response(serializer.data)
            

The @action decorator adds custom endpoints to your ViewSet:

Resulting URLs

These custom actions create the following additional endpoints:

Routers: Automatic URL Configuration

Routers automatically generate URL patterns for ViewSets, following REST conventions and reducing duplication.

Basic Router Setup


# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'books', views.BookViewSet)

urlpatterns = [
    path('api/', include(router.urls)),
]
            

This simple configuration creates the following URL patterns:

Router Types

DRF provides several router classes:

Multiple ViewSets with a Router


# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'books', views.BookViewSet)
router.register(r'authors', views.AuthorViewSet)
router.register(r'publishers', views.PublisherViewSet)

urlpatterns = [
    path('api/', include(router.urls)),
]
            

The router automatically creates all the necessary URL patterns for each ViewSet, maintaining consistent RESTful conventions across your API.

ViewSet Customization with Mixins

If you need more control over which actions are available, you can build a ViewSet using mixins:


from rest_framework import viewsets, mixins
from .models import Book
from .serializers import BookSerializer

class BookViewSet(mixins.ListModelMixin,
                  mixins.RetrieveModelMixin,
                  mixins.CreateModelMixin,
                  viewsets.GenericViewSet):
    """
    A viewset that provides `list`, `retrieve`, and `create` actions.
    """
    queryset = Book.objects.all()
    serializer_class = BookSerializer
            

This ViewSet provides only list, retrieve, and create functionality, omitting update and delete.

Available Mixins

DRF provides the following mixins for ViewSets:

By combining these mixins, you can create ViewSets with exactly the functionality you need.

Filtering, Searching, and Ordering

ViewSets can be extended with filtering capabilities to enable clients to narrow down results:


from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from .models import Book
from .serializers import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    
    # Enable filtering backends
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    
    # Fields that can be filtered via query params
    filterset_fields = ['author', 'published_date', 'is_bestseller']
    
    # Fields that can be searched with ?search=query
    search_fields = ['title', 'description', 'author__name']
    
    # Fields that can be ordered with ?ordering=field
    ordering_fields = ['title', 'published_date', 'price']
    
    # Default ordering
    ordering = ['-published_date']
            

This configuration enables:

Custom Filtering

For more complex filtering, you can override the get_queryset method:


class BookViewSet(viewsets.ModelViewSet):
    serializer_class = BookSerializer
    
    def get_queryset(self):
        queryset = Book.objects.all()
        
        # Filter by year of publication
        year = self.request.query_params.get('year')
        if year:
            queryset = queryset.filter(published_date__year=year)
        
        # Filter by minimum rating
        min_rating = self.request.query_params.get('min_rating')
        if min_rating:
            # Assuming there's a method to calculate average rating
            queryset = queryset.annotate(avg_rating=Avg('reviews__rating'))
            queryset = queryset.filter(avg_rating__gte=min_rating)
        
        return queryset
            

Permissions and Authentication

ViewSets make it easy to implement permissions for your API:


from rest_framework import viewsets, permissions
from .models import Book
from .serializers import BookSerializer
from .permissions import IsAuthorOrReadOnly

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    
    # Set permission classes
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
    
    def perform_create(self, serializer):
        # Set the author to the current user when creating a book
        serializer.save(author=self.request.user)
            

Custom Permission Class


# permissions.py
from rest_framework import permissions

class IsAuthorOrReadOnly(permissions.BasePermission):
    """
    Custom permission to only allow authors of a book to edit it.
    """
    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed for any request
        if request.method in permissions.SAFE_METHODS:
            return True
        
        # Write permissions are only allowed to the author
        return obj.author == request.user
            

Action-Specific Permissions

You can also set different permissions for different actions:


from rest_framework import viewsets, permissions
from .models import Book
from .serializers import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    
    def get_permissions(self):
        """
        Instantiates and returns the list of permissions that this view requires.
        """
        if self.action == 'list' or self.action == 'retrieve':
            permission_classes = [permissions.AllowAny]
        elif self.action == 'create':
            permission_classes = [permissions.IsAuthenticated]
        else:
            permission_classes = [permissions.IsAdminUser]
        return [permission() for permission in permission_classes]
            

Pagination

ViewSets automatically use the pagination settings defined in your settings.py, but you can also configure pagination per ViewSet:


from rest_framework import viewsets
from rest_framework.pagination import PageNumberPagination
from .models import Book
from .serializers import BookSerializer

class LargeResultsSetPagination(PageNumberPagination):
    page_size = 100
    page_size_query_param = 'page_size'
    max_page_size = 1000

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    pagination_class = LargeResultsSetPagination
            

Pagination Types

DRF provides several pagination styles:

API Versioning

As your API evolves, you might need to support multiple versions simultaneously. DRF supports versioning out of the box:


# settings.py
REST_FRAMEWORK = {
    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
    'DEFAULT_VERSION': 'v1',
    'ALLOWED_VERSIONS': ['v1', 'v2'],
    'VERSION_PARAM': 'version',
}

# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views_v1, views_v2

router_v1 = DefaultRouter()
router_v1.register(r'books', views_v1.BookViewSet)

router_v2 = DefaultRouter()
router_v2.register(r'books', views_v2.BookViewSet)

urlpatterns = [
    path('api/v1/', include(router_v1.urls)),
    path('api/v2/', include(router_v2.urls)),
]
            

Version-Specific ViewSets


# views_v1.py
from rest_framework import viewsets
from .models import Book
from .serializers_v1 import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

# views_v2.py
from rest_framework import viewsets
from .models import Book
from .serializers_v2 import BookSerializer

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
            

Versioning Styles

DRF supports several versioning styles:

Real-World Example: E-learning Platform API

Let's build a comprehensive API for an e-learning platform using ViewSets and Routers:


# models.py
from django.db import models
from django.contrib.auth.models import User

class Course(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField()
    instructor = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    
    def __str__(self):
        return self.title

class Lesson(models.Model):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='lessons')
    title = models.CharField(max_length=200)
    content = models.TextField()
    order = models.PositiveIntegerField()
    
    class Meta:
        ordering = ['order']
    
    def __str__(self):
        return self.title

class Enrollment(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enrollments')
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments')
    enrolled_at = models.DateTimeField(auto_now_add=True)
    is_completed = models.BooleanField(default=False)
    
    class Meta:
        unique_together = ('user', 'course')
    
    def __str__(self):
        return f"{self.user.username} enrolled in {self.course.title}"

class Review(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews')
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='reviews')
    rating = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
    comment = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ('user', 'course')
    
    def __str__(self):
        return f"{self.rating} stars for {self.course.title} by {self.user.username}"
            

Serializers


# serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Course, Lesson, Enrollment, Review

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

class LessonSerializer(serializers.ModelSerializer):
    class Meta:
        model = Lesson
        fields = ['id', 'title', 'content', 'order']

class ReviewSerializer(serializers.ModelSerializer):
    user = UserSerializer(read_only=True)
    
    class Meta:
        model = Review
        fields = ['id', 'rating', 'comment', 'created_at', 'user']
        read_only_fields = ['created_at', 'user']

class CourseListSerializer(serializers.ModelSerializer):
    instructor = UserSerializer(read_only=True)
    lesson_count = serializers.IntegerField(source='lessons.count', read_only=True)
    average_rating = serializers.SerializerMethodField()
    
    class Meta:
        model = Course
        fields = ['id', 'title', 'instructor', 'price', 'lesson_count', 'average_rating', 'is_published']
    
    def get_average_rating(self, obj):
        reviews = obj.reviews.all()
        if not reviews:
            return None
        return round(sum(r.rating for r in reviews) / reviews.count(), 1)

class CourseDetailSerializer(serializers.ModelSerializer):
    instructor = UserSerializer(read_only=True)
    lessons = LessonSerializer(many=True, read_only=True)
    reviews = ReviewSerializer(many=True, read_only=True)
    enrollment_count = serializers.IntegerField(source='enrollments.count', read_only=True)
    average_rating = serializers.SerializerMethodField()
    
    class Meta:
        model = Course
        fields = [
            'id', 'title', 'description', 'instructor', 'price',
            'created_at', 'updated_at', 'is_published',
            'lessons', 'reviews', 'enrollment_count', 'average_rating'
        ]
        read_only_fields = ['created_at', 'updated_at']
    
    def get_average_rating(self, obj):
        reviews = obj.reviews.all()
        if not reviews:
            return None
        return round(sum(r.rating for r in reviews) / reviews.count(), 1)

class EnrollmentSerializer(serializers.ModelSerializer):
    user = UserSerializer(read_only=True)
    course = CourseListSerializer(read_only=True)
    
    class Meta:
        model = Enrollment
        fields = ['id', 'user', 'course', 'enrolled_at', 'is_completed']
        read_only_fields = ['enrolled_at']
            

ViewSets


# views.py
from rest_framework import viewsets, mixins, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Avg
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from django.shortcuts import get_object_or_404
from .models import Course, Lesson, Enrollment, Review
from .serializers import (
    CourseListSerializer, CourseDetailSerializer, LessonSerializer,
    EnrollmentSerializer, ReviewSerializer
)
from .permissions import IsInstructorOrReadOnly, IsEnrolledOrInstructor

class CourseViewSet(viewsets.ModelViewSet):
    queryset = Course.objects.all()
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['is_published', 'price']
    search_fields = ['title', 'description', 'instructor__username']
    ordering_fields = ['title', 'created_at', 'price']
    ordering = ['-created_at']
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsInstructorOrReadOnly]
    
    def get_serializer_class(self):
        if self.action == 'list':
            return CourseListSerializer
        return CourseDetailSerializer
    
    def perform_create(self, serializer):
        serializer.save(instructor=self.request.user)
    
    def get_queryset(self):
        queryset = Course.objects.all()
        
        # Non-instructors can only see published courses
        if not self.request.user.is_authenticated or not self.request.user.courses.exists():
            queryset = queryset.filter(is_published=True)
        
        # Filter by minimum rating
        min_rating = self.request.query_params.get('min_rating')
        if min_rating:
            queryset = queryset.annotate(avg_rating=Avg('reviews__rating'))
            queryset = queryset.filter(avg_rating__gte=min_rating)
        
        return queryset
    
    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
    def enroll(self, request, pk=None):
        course = self.get_object()
        user = request.user
        
        # Check if already enrolled
        if Enrollment.objects.filter(user=user, course=course).exists():
            return Response(
                {'detail': 'You are already enrolled in this course.'},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        # Create enrollment
        enrollment = Enrollment.objects.create(user=user, course=course)
        serializer = EnrollmentSerializer(enrollment)
        return Response(serializer.data, status=status.HTTP_201_CREATED)
    
    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
    def review(self, request, pk=None):
        course = self.get_object()
        user = request.user
        
        # Check if enrolled
        if not Enrollment.objects.filter(user=user, course=course).exists():
            return Response(
                {'detail': 'You must be enrolled in this course to review it.'},
                status=status.HTTP_403_FORBIDDEN
            )
        
        # Check if already reviewed
        if Review.objects.filter(user=user, course=course).exists():
            return Response(
                {'detail': 'You have already reviewed this course.'},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        # Create review
        serializer = ReviewSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save(user=user, course=course)
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
    
    @action(detail=True, methods=['get'])
    def lessons(self, request, pk=None):
        course = self.get_object()
        
        # If not instructor, check if enrolled
        if course.instructor != request.user and not Enrollment.objects.filter(
            user=request.user, course=course
        ).exists():
            return Response(
                {'detail': 'You must be enrolled to view lessons.'},
                status=status.HTTP_403_FORBIDDEN
            )
        
        lessons = course.lessons.all()
        serializer = LessonSerializer(lessons, many=True)
        return Response(serializer.data)

class LessonViewSet(viewsets.ModelViewSet):
    queryset = Lesson.objects.all()
    serializer_class = LessonSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsEnrolledOrInstructor]
    
    def get_queryset(self):
        # Get course_id from URL if present
        course_id = self.request.query_params.get('course')
        if course_id:
            return Lesson.objects.filter(course_id=course_id)
        return Lesson.objects.all()
    
    def perform_create(self, serializer):
        course_id = self.request.data.get('course')
        course = get_object_or_404(Course, id=course_id)
        
        # Ensure only the instructor can add lessons
        if course.instructor != self.request.user:
            self.permission_denied(
                self.request, 
                message='You do not have permission to add lessons to this course.'
            )
        
        serializer.save(course=course)

class EnrollmentViewSet(viewsets.ReadOnlyModelViewSet, mixins.UpdateModelMixin):
    serializer_class = EnrollmentSerializer
    permission_classes = [permissions.IsAuthenticated]
    
    def get_queryset(self):
        # Students see only their enrollments
        if not self.request.user.courses.exists():
            return Enrollment.objects.filter(user=self.request.user)
        
        # Instructors see enrollments for their courses
        return Enrollment.objects.filter(course__instructor=self.request.user)
    
    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
    def mark_completed(self, request, pk=None):
        enrollment = self.get_object()
        
        # Only the enrolled user can mark as completed
        if enrollment.user != request.user:
            return Response(
                {'detail': 'You do not have permission to perform this action.'},
                status=status.HTTP_403_FORBIDDEN
            )
        
        enrollment.is_completed = True
        enrollment.save()
        serializer = self.get_serializer(enrollment)
        return Response(serializer.data)
            

Custom Permissions


# permissions.py
from rest_framework import permissions

class IsInstructorOrReadOnly(permissions.BasePermission):
    """
    Custom permission to only allow instructors to edit courses.
    """
    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed for any request
        if request.method in permissions.SAFE_METHODS:
            return True
        
        # Write permissions are only allowed to the instructor
        return obj.instructor == request.user

class IsEnrolledOrInstructor(permissions.BasePermission):
    """
    Permission to only allow access to lessons if enrolled or instructor.
    """
    def has_object_permission(self, request, view, obj):
        # Instructor always has access
        if obj.course.instructor == request.user:
            return True
        
        # Read access for enrolled users
        if request.method in permissions.SAFE_METHODS:
            return request.user.enrollments.filter(course=obj.course).exists()
        
        # Write access only for the instructor
        return False
            

Router Configuration


# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'courses', views.CourseViewSet)
router.register(r'lessons', views.LessonViewSet)
router.register(r'enrollments', views.EnrollmentViewSet, basename='enrollment')

urlpatterns = [
    path('api/', include(router.urls)),
    path('api-auth/', include('rest_framework.urls')),
]
            

This comprehensive example demonstrates:

Practice Activities

  1. Basic ViewSet: Convert the Book API from previous lectures to use a ModelViewSet and a DefaultRouter. Compare the code reduction and URL patterns generated.
  2. Custom Actions: Add custom actions to your BookViewSet, such as 'mark_as_read' and 'recommend', that perform different operations on books.
  3. Related ViewSets: Extend your API with Author and Genre ViewSets, connecting them appropriately with your Book ViewSet. Use a shared router for all ViewSets.
  4. Advanced Challenge: Implement a "library" system where users can borrow books. Create a LoanViewSet with appropriate permissions so users can only see their own loans, while librarians (a new user role) can see and manage all loans.

Key Takeaways

Django REST Framework's ViewSets and Routers represent the highest level of abstraction for API development, allowing you to focus on business logic rather than repetitive CRUD operations and URL configuration. By leveraging these powerful tools, you can build robust, consistent APIs with significantly less code.

With the knowledge from all three lectures in this module, you now have a comprehensive understanding of Django REST Framework and the tools it provides for building modern web APIs. You can create serializers for data transformation, views for handling HTTP methods, and ViewSets with routers for streamlining the entire API development process.