Table of Contents
- Django REST Framework Assignments — Complete Tutorial Guide
- Prerequisites
- DRF Compact Style Used in These Assignments
- Assignment 01: Basic To-Do API
- Assignment 02: Personal Blog
- Assignment 03: User Profile & Uploads
- Assignment 04: Task Manager with Background Tasks
- Assignment 05: Weather Dashboard
- Assignment 06: Real-time Chat
- Assignment 07: Form Handling & Validation
- Assignment 08: Webhook Receiver
- Assignment 09: Inventory Management
- Assignment 10: Secure Document Vault
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
ModelSerializerandread_only_fieldsModelViewSetfor full CRUD in minimal codeDefaultRouterfor automatic URL generation- The
updated_atfield andauto_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_fieldon a ViewSet for non-PK lookupsSlugFieldvalidation 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
MultiPartParserandFormParseron a ViewSetSerializerMethodFieldfor computed output fieldsrequest.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_updatehook on a ViewSet - Auto-setting
completed_atwithtimezone.now() transaction.on_commitin 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_paramsin DRF views- Returning a
sourcefield 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_createon a ViewSet for side effects after creationasync_to_syncfor 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
- Start with Daphne or Uvicorn.
- Open
http://127.0.0.1:8000/in two browser tabs (the template provides a WebSocket chat UI). - Send a message from Tab 1. It appears in Tab 2 instantly.
- In a separate terminal, POST a message via the REST API:
The message appears in both browser tabs — the REST API and the WebSocket are wired together.curl -X POST http://127.0.0.1:8000/api/messages/ \ -H "Content-Type: application/json" \ -d '{"username": "api-client", "text": "Hello from REST"}'
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.bodyin a DRF view - HMAC + timestamp + deduplication (same security model as the classical variant)
- Why DRF
APIViewis 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_id→409 {"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
SerializerMethodFieldfor a computed boolean field- Filtering with
Qobjects insideget_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 ViewSetperform_createto auto-assign the authenticated user as ownerget_querysetfor object-level filtering based on the current userSerializerMethodFieldfor 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>"}'