1 Django REST Framework Assignments
teacher edited this page 2026-04-13 14:53:00 +02:00

Django REST Framework Assignments — Complete Tutorial Guide

This guide walks through all ten assignments in the django-assignments/restapi/ directory. Every assignment is a standalone Django project that uses Django REST Framework (DRF) to expose JSON APIs. The project layout is identical to the classical variant: config/ for settings and app/ for application code.

A distinctive feature of these assignments is that the serialiser and ViewSet classes live directly in app/views.py rather than separate files. This is intentional for learning: everything related to one endpoint is visible on one screen.


Prerequisites

Python Version

python --version   # 3.10 or later

Virtual Environment & Installation

python -m venv venv
source venv/bin/activate
pip install Django djangorestframework

Running Any Assignment

python manage.py migrate
python manage.py runserver

The server listens at http://127.0.0.1:8000. Visiting any DRF endpoint in a browser shows the Browsable API — a human-readable HTML interface for exploring and testing the API without Postman.

The health Endpoint

Every assignment exposes GET / which returns:

{"framework": "django", "variant": "restapi", "status": "ok"}

This is a quick sanity check that the server is running correctly.


DRF Compact Style Used in These Assignments

All serialisers and ViewSets are defined inline in views.py. Example from assignment 01:

# Everything in one file: import → model → serializer → viewset
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from app.models import Todo

class TodoSerializer(ModelSerializer):
    class Meta:
        model  = Todo
        fields = ["id", "title", "description", "done", "created_at", "updated_at"]
        read_only_fields = ["id", "created_at", "updated_at"]

class TodoViewSet(ModelViewSet):
    queryset         = Todo.objects.all().order_by("-created_at")
    serializer_class = TodoSerializer

Then in urls.py:

from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("todos", TodoViewSet, basename="todo")
urlpatterns = [path("api/", include(router.urls))]

DefaultRouter generates all six standard endpoints from one registration:

Method URL Action
GET /api/todos/ list
POST /api/todos/ create
GET /api/todos/{id}/ retrieve
PUT /api/todos/{id}/ update
PATCH /api/todos/{id}/ partial_update
DELETE /api/todos/{id}/ destroy

Assignment 01: Basic To-Do API

Location: django-assignments/restapi/01-basic-todo-api/

What You Will Learn

  • ModelSerializer and read_only_fields
  • ModelViewSet for full CRUD in minimal code
  • DefaultRouter for automatic URL generation
  • The updated_at field and auto_now=True

Key Concepts

read_only_fields prevents clients from supplying values for system-managed fields. id is assigned by the database; created_at and updated_at are set by Django. Listing them as read-only means DRF ignores them during creation and update, even if the client sends them.

auto_now=True on updated_at updates the timestamp on every save(). This is tracked separately from created_at (auto_now_add=True), allowing you to detect whether a record was recently modified.

ModelViewSet provides list, create, retrieve, update, partial_update, and destroy actions in a single class. Each action calls the serialiser's is_valid() and save() automatically. Overriding is possible at any level: override perform_create to add extra logic on creation, get_queryset to filter results, or update for custom save behaviour.

Setup

pip install Django djangorestframework
python manage.py migrate
python manage.py runserver

Testing Step by Step

# Create
curl -X POST http://127.0.0.1:8000/api/todos/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries", "description": "Milk and eggs"}'

# List
curl http://127.0.0.1:8000/api/todos/

# Partial update (mark done)
curl -X PATCH http://127.0.0.1:8000/api/todos/1/ \
  -H "Content-Type: application/json" \
  -d '{"done": true}'

# Delete
curl -X DELETE http://127.0.0.1:8000/api/todos/1/

Also visit http://127.0.0.1:8000/api/todos/ in a browser — the DRF Browsable API lets you POST directly from the page.


Assignment 02: Personal Blog

Location: django-assignments/restapi/02-personal-blog/

What You Will Learn

  • lookup_field on a ViewSet for non-PK lookups
  • SlugField validation in a serialiser
  • URL routing with slug instead of integer ID

Key Concepts

lookup_field = "slug" on the ViewSet tells DRF to use the slug field instead of the default pk for detail URLs. The router generates /api/posts/{slug}/ instead of /api/posts/{id}/. No further configuration is required — DRF reads the lookup_field setting and adjusts routing and queries automatically.

class PostViewSet(ModelViewSet):
    queryset         = Post.objects.all().order_by("-created_at")
    serializer_class = PostSerializer
    lookup_field     = "slug"

Unique constraint on the serialiser: SlugField(unique=True) on the model creates a database constraint. DRF's ModelSerializer detects this constraint and automatically adds a UniqueValidator to the serialiser field. Attempts to create a post with a duplicate slug return a 400 Bad Request with a descriptive error message.

Setup

pip install Django djangorestframework
python manage.py makemigrations
python manage.py migrate
python manage.py runserver

Testing Step by Step

curl -X POST http://127.0.0.1:8000/api/posts/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello World", "slug": "hello-world", "content": "First post."}'

# Retrieve by slug (not by id)
curl http://127.0.0.1:8000/api/posts/hello-world/

# Update
curl -X PATCH http://127.0.0.1:8000/api/posts/hello-world/ \
  -H "Content-Type: application/json" \
  -d '{"content": "Updated content."}'

# Duplicate slug → 400
curl -X POST http://127.0.0.1:8000/api/posts/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Other", "slug": "hello-world", "content": "..."}'

Assignment 03: User Profile & Uploads

Location: django-assignments/restapi/03-user-profile-uploads/

What You Will Learn

  • MultiPartParser and FormParser on a ViewSet
  • SerializerMethodField for computed output fields
  • request.build_absolute_uri() for generating full URLs
  • Passing the request object through serialiser context

Key Concepts

SerializerMethodField adds a read-only field whose value is computed by a method on the serialiser:

class ProfileSerializer(ModelSerializer):
    avatar_url = SerializerMethodField()

    class Meta:
        model  = Profile
        fields = ["id", "display_name", "bio", "avatar", "avatar_url", "created_at"]
        read_only_fields = ["id", "created_at", "avatar_url"]

    def get_avatar_url(self, obj):
        request = self.context.get("request")
        if not obj.avatar:
            return None
        if request is None:
            return obj.avatar.url          # relative path
        return request.build_absolute_uri(obj.avatar.url)  # full URL

obj.avatar.url produces a relative path like /media/avatars/photo.jpg. request.build_absolute_uri(...) prepends the scheme and host to produce http://127.0.0.1:8000/media/avatars/photo.jpg. This full URL is directly usable in a frontend without string concatenation.

Passing the request to the serialiser: get_serializer_context() is overridden on the ViewSet to ensure the request object is available in the serialiser's context:

def get_serializer_context(self):
    ctx = super().get_serializer_context()
    ctx["request"] = self.request
    return ctx

ModelViewSet calls get_serializer_context() automatically when creating serialiser instances — no extra wiring is needed in handlers.

parser_classes = [MultiPartParser, FormParser] tells DRF to parse the request body as multipart form data (for file uploads) or URL-encoded form data. Without this, DRF defaults to JSON parsing and file uploads would fail with a 415 Unsupported Media Type error.

Setup

pip install Django djangorestframework pillow
python manage.py migrate
python manage.py runserver

Testing Step by Step

# Create a profile (multipart)
curl -X POST http://127.0.0.1:8000/api/profiles/ \
  -F "display_name=Alice" \
  -F "bio=Developer" \
  -F "avatar=@./photo.jpg"

# Retrieve — note avatar_url is a full http:// URL
curl http://127.0.0.1:8000/api/profiles/1/

# Update bio only (no file change)
curl -X PATCH http://127.0.0.1:8000/api/profiles/1/ \
  -F "bio=Senior Developer"

Assignment 04: Task Manager with Background Tasks

Location: django-assignments/restapi/04-task-manager-bg-tasks/

What You Will Learn

  • Custom update() method on a serialiser
  • The perform_update hook on a ViewSet
  • Auto-setting completed_at with timezone.now()
  • transaction.on_commit in an API context

Key Concepts

Custom update() on the serialiser runs additional logic after the standard field update:

def update(self, instance, validated_data):
    completed_before = instance.completed
    instance = super().update(instance, validated_data)
    if not completed_before and instance.completed and instance.completed_at is None:
        instance.completed_at = timezone.now()
        instance.save(update_fields=["completed_at"])
    return instance

The check not completed_before and instance.completed ensures completed_at is set only on the transition from incomplete to complete — not when updating an already-completed task, and not when the client explicitly sets completed: false.

perform_update on the ViewSet fires after serializer.save() and is a good place for side effects that should not be inside the serialiser:

def perform_update(self, serializer):
    before  = self.get_object()       # state before save
    updated = serializer.save()       # triggers serializer.update()
    if not before.completed and updated.completed:
        _notify_after_commit(updated.id)

Separation of concerns: the serialiser handles data transformation; the viewset hook handles application-level side effects.

timezone.now() returns an aware datetime using the timezone configured in settings.py (USE_TZ = True). Always prefer this over datetime.datetime.now() in Django applications.

Setup

pip install Django djangorestframework
python manage.py migrate
python manage.py runserver

Testing Step by Step

# Create
curl -X POST http://127.0.0.1:8000/api/tasks/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Write report"}'

# Complete (PATCH — partial update)
curl -X PATCH http://127.0.0.1:8000/api/tasks/1/ \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Retrieve — note completed_at is now set
curl http://127.0.0.1:8000/api/tasks/1/

# Patch again — completed_at does not change
curl -X PATCH http://127.0.0.1:8000/api/tasks/1/ \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

Assignment 05: Weather Dashboard

Location: django-assignments/restapi/05-weather-dashboard/

What You Will Learn

  • request.query_params in DRF views
  • Returning a source field to indicate cache vs live data
  • Handling upstream API failures with 502 Bad Gateway
  • Function-based DRF views with @api_view

Key Concepts

@api_view(["GET"]) is the function-based equivalent of APIView. It is suitable for one-off endpoints that do not fit the CRUD pattern of a ViewSet. The decorated function receives a DRF Request object and must return a DRF Response.

request.query_params is DRF's name for request.GET. It is a QueryDict object:

city = (request.query_params.get("city") or "").strip()
if not city:
    return Response({"detail": "city is required"}, status=400)

Explicit source field in the response:

if cached is not None:
    return Response({"source": "cache", "city": city, "data": cached})
# ...
return Response({"source": "api",   "city": city, "data": data})

Including source in the response lets the client (and developer) distinguish a live API result from a cached one without consulting server logs.

status=502 (Bad Gateway) is the semantically correct status when a downstream API fails. The server received the request and understood it, but could not obtain a valid response from an upstream service.

Setup

pip install Django djangorestframework requests
python manage.py migrate
python manage.py runserver

Testing Step by Step

# Live API call
curl "http://127.0.0.1:8000/api/weather/?city=London"
# Response includes "source": "api"

# Second call within 5 minutes — from cache
curl "http://127.0.0.1:8000/api/weather/?city=London"
# Response includes "source": "cache"

# Missing city parameter
curl "http://127.0.0.1:8000/api/weather/"
# 400: {"detail": "city is required"}

Assignment 06: Real-time Chat

Location: django-assignments/restapi/06-real-time-chat/

What You Will Learn

  • Combining a REST API (DRF ViewSet) with WebSockets (Django Channels) in one project
  • perform_create on a ViewSet for side effects after creation
  • async_to_sync for calling async Channel layer code from a synchronous DRF view
  • How REST and WebSocket transports complement each other

Key Concepts

Two transports, one model: Message records are stored in the database. DRF's MessageViewSet handles GET /api/messages/ (message history) and POST /api/messages/ (create a message). The WebSocket consumer handles real-time delivery. Sending via the REST API also pushes the message to all WebSocket-connected clients:

class MessageViewSet(ModelViewSet):
    def perform_create(self, serializer):
        msg          = serializer.save()
        channel_layer = get_channel_layer()
        async_to_sync(channel_layer.group_send)(
            "chat",
            {"type": "chat.message", "username": msg.username, "text": msg.text},
        )

async_to_sync from asgiref wraps an async function so it can be called from synchronous code. DRF views are synchronous; Django Channels' group_send is an async coroutine. async_to_sync bridges the two.

WebSocket consumers work identically to the classical variant. The ChatConsumer adds itself to the "chat" group on connect and forwards chat.message events to the client:

async def chat_message(self, event):
    await self.send(text_data=json.dumps({
        "username": event["username"],
        "text":     event["text"]
    }))

This method is invoked by the channel layer whenever group_send publishes a {"type": "chat.message", ...} event to the "chat" group.

Setup

pip install Django djangorestframework channels channels-redis
python manage.py migrate
daphne config.asgi:application   # or: uvicorn config.asgi:application

Testing Step by Step

  1. Start with Daphne or Uvicorn.
  2. Open http://127.0.0.1:8000/ in two browser tabs (the template provides a WebSocket chat UI).
  3. Send a message from Tab 1. It appears in Tab 2 instantly.
  4. In a separate terminal, POST a message via the REST API:
    curl -X POST http://127.0.0.1:8000/api/messages/ \
      -H "Content-Type: application/json" \
      -d '{"username": "api-client", "text": "Hello from REST"}'
    
    The message appears in both browser tabs — the REST API and the WebSocket are wired together.

Assignment 07: Form Handling & Validation

Location: django-assignments/restapi/07-forms-templates/

What You Will Learn

  • Using a ViewSet as a pure validation + storage endpoint
  • Restricting allowed HTTP methods on a ViewSet with http_method_names
  • The relationship between DRF serialisers and HTML forms (conceptually)

Key Concepts

http_method_names = ["get", "post", "head", "options"] restricts the ViewSet to only GET and POST. DELETE, PUT, and PATCH return 405 Method Not Allowed. This is appropriate for a contact form endpoint — you can submit messages and retrieve them, but not edit or delete them.

class ContactMessageViewSet(ModelViewSet):
    queryset         = ContactMessage.objects.all().order_by("-created_at")
    serializer_class = ContactMessageSerializer
    http_method_names = ["get", "post", "head", "options"]

DRF validation errors are structured JSON. If email or topic is missing, DRF returns:

{
  "email": ["This field is required."],
  "topic": ["This field is required."]
}

This predictable format allows a JavaScript frontend to display per-field error messages next to the appropriate input.

EmailField in the serialiser validates email format automatically. No custom validator is needed — DRF inherits the validation logic from Django's EmailField.

Setup

pip install Django djangorestframework
python manage.py migrate
python manage.py runserver

Testing Step by Step

# Valid submission
curl -X POST http://127.0.0.1:8000/api/contact/ \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "topic": "Support", "message": "Hello"}'

# Missing fields — structured error response
curl -X POST http://127.0.0.1:8000/api/contact/ \
  -H "Content-Type: application/json" \
  -d '{"email": "not-an-email"}'

# List all messages
curl http://127.0.0.1:8000/api/contact/

# Attempt DELETE — 405
curl -X DELETE http://127.0.0.1:8000/api/contact/1/

Assignment 08: Webhook Receiver

Location: django-assignments/restapi/08-webhook-receiver/

What You Will Learn

  • Reading request.body in a DRF view
  • HMAC + timestamp + deduplication (same security model as the classical variant)
  • Why DRF APIView is CSRF-exempt by default
  • Responding before and after signature validation

Key Concepts

DRF and CSRF: Standard Django views require a CSRF token for POST requests from browsers. DRF's APIView (and @api_view) are exempt from CSRF for session-less authentication because they are designed for API clients that do not maintain sessions. No @csrf_exempt decorator is needed.

request.body in DRF: The underlying request.body attribute works identically to standard Django. It must be read before accessing request.data (which parses the body, emptying the stream). The pattern:

raw      = request.body                        # raw bytes
provided = request.headers.get("X-Signature", "")
expected = hmac.new(
    settings.WEBHOOK_SECRET.encode("utf-8"),
    raw,
    hashlib.sha256
).hexdigest()
valid = hmac.compare_digest(expected, provided)

try:
    payload = json.loads(raw.decode("utf-8"))
except Exception:
    payload = {"raw": raw.decode("utf-8", errors="replace")}

Note that the event is always stored, even if the signature is invalid (signature_valid=False). This audit trail is valuable: you can review rejected events to detect attacks or misconfigured senders.

Provider-scoped URLs (/api/webhooks/{provider}/) allow a single application to receive webhooks from multiple services (GitHub, Stripe, etc.) and route them based on the provider parameter.

Setup

pip install Django djangorestframework
python manage.py migrate
python manage.py runserver

Set WEBHOOK_SECRET = "change-me" in config/settings.py before testing.

Testing Step by Step

import hashlib, hmac, json, time
import requests

SECRET   = "change-me"
body     = json.dumps({"event": "push"}).encode()
ts       = str(int(time.time()))
event_id = "evt-abc-001"
sig      = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()

resp = requests.post(
    "http://127.0.0.1:8000/api/webhooks/github/",
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Signature":  sig,
        "X-Timestamp":  ts,
        "X-Event-Id":   event_id,
    }
)
print(resp.status_code, resp.json())
  • First run → 200 {"status": "ok"}
  • Second run with same event_id409 {"detail": "duplicate event"}
  • Wrong secret → 401 {"detail": "invalid signature"}

Assignment 09: Inventory Management

Location: django-assignments/restapi/09-inventory-management/

What You Will Learn

  • Two separate DRF routers for API versioning
  • SerializerMethodField for a computed boolean field
  • Filtering with Q objects inside get_queryset
  • The difference between V1 and V2 response shapes

Key Concepts

Two routers, two ViewSets:

router_v1 = DefaultRouter()
router_v1.register("items", ItemV1ViewSet, basename="item-v1")

router_v2 = DefaultRouter()
router_v2.register("items", ItemV2ViewSet, basename="item-v2")

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

ItemV1ViewSet uses ItemV1Serializer (no computed fields). ItemV2ViewSet uses ItemV2Serializer which adds in_stock:

class ItemV2Serializer(ModelSerializer):
    in_stock = SerializerMethodField()
    ...
    def get_in_stock(self, obj):
        return obj.quantity > 0

in_stock is a derived field — it is not stored in the database, it is computed from quantity on every serialisation. This is a clean way to add business-logic computed properties to an API response without altering the data model.

get_queryset for search filtering: Both ViewSets override get_queryset to apply optional search:

def get_queryset(self):
    qs = Item.objects.all().order_by("-created_at")
    q  = (self.request.query_params.get("q") or "").strip()
    if q:
        qs = qs.filter(Q(name__icontains=q) | Q(sku__icontains=q))
    return qs

Overriding get_queryset is the correct place to filter in a ViewSet, as it applies consistently to list, retrieve, and all other actions.

DRF pagination: Add to config/settings.py to enable automatic pagination:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
    "PAGE_SIZE": 20,
}

With this setting, GET /api/v1/items/ returns {"count": N, "next": "...", "previous": "...", "results": [...]}.

Setup

pip install Django djangorestframework
python manage.py makemigrations
python manage.py migrate
python manage.py runserver

Testing Step by Step

# V1 — no in_stock field
curl "http://127.0.0.1:8000/api/v1/items/"

# V2 — includes in_stock
curl "http://127.0.0.1:8000/api/v2/items/"

# Search
curl "http://127.0.0.1:8000/api/v2/items/?q=bolt"

# Create an item, then check in_stock
curl -X POST http://127.0.0.1:8000/api/v1/items/ \
  -H "Content-Type: application/json" \
  -d '{"name": "Steel bolt", "sku": "BOLT-001", "quantity": 0}'

curl http://127.0.0.1:8000/api/v2/items/1/
# "in_stock": false  (quantity is 0)

Assignment 10: Secure Document Vault

Location: django-assignments/restapi/10-secure-document-vault/

What You Will Learn

  • JWT authentication with djangorestframework-simplejwt
  • permission_classes = [IsAuthenticated] on a ViewSet
  • perform_create to auto-assign the authenticated user as owner
  • get_queryset for object-level filtering based on the current user
  • SerializerMethodField for a full file URL

Key Concepts

djangorestframework-simplejwt provides two endpoints out of the box, wired directly in urls.py:

from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("api/token/",         TokenObtainPairView.as_view()),
    path("api/token/refresh/", TokenRefreshView.as_view()),
    path("api/", include(router.urls)),
]

POST /api/token/ with {"username": "...", "password": "..."} returns {"access": "...", "refresh": "..."}. Include the access token in subsequent requests: Authorization: Bearer <access>.

permission_classes = [IsAuthenticated] rejects requests without a valid token with 401 Unauthorized. The entire ViewSet is protected — no endpoint is publicly accessible.

perform_create is called by the ViewSet after serializer.is_valid() but before the response is returned. It is the correct place to inject the owner:

def perform_create(self, serializer):
    serializer.save(owner=self.request.user)

The owner field is listed as read_only in the serialiser so clients cannot forge ownership. The ViewSet sets it from the authenticated user.

get_queryset for object-level isolation:

def get_queryset(self):
    user = self.request.user
    if _is_admin(user):
        return Document.objects.all().order_by("-created_at")
    return Document.objects.filter(owner=user).order_by("-created_at")

A non-admin user can only see and retrieve their own documents. Attempting GET /api/documents/5/ for a document owned by another user returns 404 Not Found — not 403 Forbidden. The 404 avoids leaking whether document 5 exists.

file_url with SerializerMethodField generates an absolute URL identical to the approach in assignment 03:

def get_file_url(self, obj):
    request = self.context.get("request")
    if not obj.file:
        return None
    return request.build_absolute_uri(obj.file.url) if request else obj.file.url

Setup

pip install Django djangorestframework djangorestframework-simplejwt
python manage.py migrate
python manage.py createsuperuser   # creates the first user for testing
python manage.py runserver

Testing Step by Step

# Get a JWT token for the superuser
curl -X POST http://127.0.0.1:8000/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "your_password"}'

# Store the access token
TOKEN="<access_token_from_response>"

# Upload a document
curl -X POST http://127.0.0.1:8000/api/documents/ \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=My Report" \
  -F "file=@./report.pdf"

# List documents
curl http://127.0.0.1:8000/api/documents/ \
  -H "Authorization: Bearer $TOKEN"

# No token — 401
curl http://127.0.0.1:8000/api/documents/

Multi-user test: Create a second user in the Django admin. Get a token for that user. List documents — the list is empty (the second user owns no documents). Try to retrieve GET /api/documents/1/ — you get 404, not 403.

Token refresh: Access tokens expire (default 5 minutes in simplejwt). Use the refresh token:

curl -X POST http://127.0.0.1:8000/api/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh": "<refresh_token>"}'