Django
In this notebook we build a blog platform with Django and Django REST Framework (DRF). We start with one plain JSON view and end with a token-protected REST API, nested comment URLs and a customised admin site.
Learning Objectives
By the end of this notebook you will be able to:
- Create a Django project and app from scratch
- Define models and run database migrations
- Query the database with the Django ORM
- Write function-based and class-based views and connect them to URLs
- Build a REST API with DRF serializers, ViewSets and routers
- Protect endpoints with token authentication and permissions
- Manage your data with the Django admin site
Prerequisites
- Python 3.10 or newer
- The OOP notebook (classes, inheritance, decorators) and some HTTP basics
- The FastAPI notebook is helpful but not required. We compare the two frameworks a few times.
Install everything used in this notebook:
pip install django djangorestframework drf-nested-routers django-filter
How to Use This Notebook
- Work through it top to bottom. Each section adds to the same project, called
blogplatform. - The code cells are not meant to be run inside Jupyter. Django code has to live inside a project. Each cell tells you where it goes:
# file: blog/models.pymeans the cell is the full content of that file. Create (or replace) the file and paste it in.# add to: blogplatform/settings.pymeans paste the cell into the existing file.# run in: python manage.py shellmeans type or paste it into the Django shell.- Cells marked Illustration are short pieces to read, not to copy.
- Run terminal commands (
python manage.py ...) from the outerblogplatform/folder, the one that containsmanage.py. - Try each Exercise yourself before opening Show solution.
- Each section ends with a Quick recap. Read just the recaps when revising.
What We're Building
We will build the Blog Platform: a web application with a REST API for blog posts, comments and authors.
Browser / curl / Postman
|
| HTTP
v
[ Django + DRF ] <-- your Python code
|
| ORM
v
[ SQLite ] <-- db.sqlite3
Final Endpoint Table
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/posts/ |
None | List all posts |
| POST | /api/posts/ |
Token | Create a post |
| GET | /api/posts/{id}/ |
None | Get one post with comments |
| PUT | /api/posts/{id}/ |
Token + Owner | Update a post |
| DELETE | /api/posts/{id}/ |
Token + Owner | Delete a post |
| POST | /api/posts/{id}/publish/ |
Token + Owner | Publish a post |
| POST | /api/posts/{id}/unpublish/ |
Token + Owner | Unpublish a post |
| GET | /api/posts/{id}/comments/ |
None | List comments on a post |
| POST | /api/posts/{id}/comments/ |
Token | Add a comment |
| POST | /api/auth/register/ |
None | Register a user |
| POST | /api/auth/token/ |
None | Get an auth token |
| DELETE | /api/auth/token/revoke/ |
Token | Log out (delete token) |
Project Structure
blogplatform/
├── manage.py
├── blogplatform/ <- project config
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── blog/ <- our app
├── models.py
├── serializers.py
├── views.py
├── urls.py
├── permissions.py
└── admin.py
Quick recap
- One Django project (
blogplatform) holds the settings; one app (blog) holds our code. - Anyone can read posts and comments. Writing needs a token, and only the author may change their own post.
1. Setup & First View
Django vs FastAPI
| Django | FastAPI | |
|---|---|---|
| Philosophy | Batteries included: ORM, admin, auth and templates built in | Small core, add what you need |
| Data validation | Forms / DRF serializers | Pydantic models |
| Auto docs | Not built in (DRF gives a browsable API) | Swagger UI + ReDoc |
| Async support | Async views since 3.1; ORM mostly sync | Native async/await |
| Best for | Full web apps, content sites, admin-heavy projects | Fast APIs, microservices |
Creating a Project
django-admin startproject blogplatform
cd blogplatform
python manage.py startapp blog
startproject creates the configuration package (settings, root URLs). startapp creates one app: a self-contained folder with its own models, views and URLs. A Django project is made of one or more apps.
Registering the App
Open blogplatform/settings.py and add DRF and our app to the INSTALLED_APPS list that's already there:
# add to: blogplatform/settings.py (edit the existing INSTALLED_APPS)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework', # <- Django REST Framework
'rest_framework.authtoken', # <- Token authentication
'blog', # <- our app
]
Running the Development Server
python manage.py migrate # apply the built-in migrations (creates db.sqlite3)
python manage.py runserver # start the dev server at http://127.0.0.1:8000
Your First View
A view is a Python function (or class) that receives an HttpRequest and returns an HttpResponse. JsonResponse is an HttpResponse that turns a dict into JSON for you.
# file: blog/views.py (Section 1 checkpoint)
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok", "version": "1.0.0"})
Now connect it to a URL. The app gets its own urls.py, and the project's urls.py includes it under api/:
# file: blog/urls.py (Section 1 checkpoint; create this file)
from django.urls import path
from . import views
urlpatterns = [
path('health/', views.health),
]
# file: blogplatform/urls.py (include the app URLs)
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('blog.urls')),
]
Visit http://127.0.0.1:8000/api/health/ and Django returns {"status": "ok", "version": "1.0.0"}.
The full URL is the two pieces joined: api/ from the project's urls.py plus health/ from the app's urls.py.
Exercise 1: Ping
Add a GET /api/ping/ view that returns {"message": "pong", "timestamp": <current UTC time as a string>}. Use datetime from Python's standard library.
Show solution
# add to: blog/views.py
from datetime import datetime, timezone
def ping(request):
return JsonResponse({
"message": "pong",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
# add to: blog/urls.py (inside urlpatterns)
# path('ping/', views.ping),
Quick recap
startprojectmakes the config package;startappmakes an app. Add the app toINSTALLED_APPS.- A view takes a request and returns a response.
JsonResponse(dict)returns JSON. - URLs are split: the project's
urls.pyusesinclude()to hand a prefix over to the app'surls.py.
2. Models & Migrations
What Is a Django Model?
A model is a Python class that maps to a database table. Each class attribute is a column. Django's ORM turns Python code into SQL for you.
# Illustration: the smallest possible model
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
Django names the table blog_post: app name plus model name, in lowercase.
Common Field Types
| Field | Python type | SQL type |
|---|---|---|
CharField(max_length=N) |
str |
VARCHAR(N) |
TextField() |
str |
TEXT |
IntegerField() |
int |
INTEGER |
FloatField() |
float |
REAL |
BooleanField() |
bool |
BOOLEAN |
DateTimeField(auto_now_add=True) |
datetime |
DATETIME (set once, on create) |
DateTimeField(auto_now=True) |
datetime |
DATETIME (set on every save) |
ForeignKey(Model, on_delete=...) |
the related object | INTEGER + foreign key constraint |
Relationships
# Illustration (fields shortened)
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
# CASCADE: deleting a Post also deletes all its Comments
# related_name: lets you do post.comments.all()
The Migration Workflow
Edit models.py
|
v
python manage.py makemigrations <- writes a migration file in blog/migrations/
|
v
python manage.py migrate <- runs it against the database
Run both commands every time you change a model. Don't edit migration files by hand unless you really know what you're doing.
Inspecting the Schema
python manage.py sqlmigrate blog 0001
# prints the SQL Django will run. Great for learning what the ORM does.
Here is the full models.py for the project:
# file: blog/models.py (Section 2 checkpoint)
from django.db import models
from django.contrib.auth.models import User
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
body = models.TextField()
published = models.BooleanField(default=False)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='posts',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at'] # newest first by default
def __str__(self):
return self.title
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments')
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Comment by {self.author} on "{self.post}"'
What just happened?
authorlinks each post to Django's built-inUsermodel, so we get user accounts and passwords for free.related_name='posts'means you can writealice.posts.all(). Likewisepost.comments.all()for comments.class Meta: ordering = ['-created_at']makes every query return the newest posts first unless you ask otherwise.__str__controls how a post is shown in the shell and the admin site.
Now create the tables:
python manage.py makemigrations blog
python manage.py migrate
Exercise 2: Slug and Published Flag
Suppose your Post model started without slug and published. Add a slug field (SlugField(unique=True)) and a published boolean field (default False), run makemigrations and migrate, and check the new columns with sqlmigrate.
(The checkpoint above already includes both, so compare your answer with it.)
Show solution
# add to: blog/models.py (inside class Post)
slug = models.SlugField(unique=True)
published = models.BooleanField(default=False)
# Then in the terminal:
# python manage.py makemigrations blog
# python manage.py migrate
# python manage.py sqlmigrate blog 0002 # the number of your new migration
#
# If the table already has rows, makemigrations asks for a default for `slug`.
# A unique field can't share one default, so the easiest fix while learning is
# to delete db.sqlite3 and the files in blog/migrations/ (keep __init__.py)
# and start again.
Quick recap
- A model is a class; each field is a column. Django adds an
idprimary key for you. ForeignKey(..., on_delete=models.CASCADE, related_name='...')links tables and gives you a reverse lookup.- Changed a model?
makemigrations, thenmigrate. Every time.
3. Django ORM
All the code in this section runs in the Django shell, a Python prompt with your project already loaded:
python manage.py shell
Start with this session. It creates the user alice and a first post, which the later examples use. Run it only once: creating alice a second time fails because usernames are unique.
Since Django 5.2 the shell imports your models automatically. The import lines in the cells are still there so the code also works in older versions and in plain scripts.
# run in: python manage.py shell
# Only needed if you run this as a plain script instead of in the shell:
# import django, os
# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'blogplatform.settings')
# django.setup()
from django.contrib.auth.models import User
from blog.models import Post, Comment
# Create a user (only needed once)
alice = User.objects.create_user(username='alice', password='secret123')
# CREATE
post = Post.objects.create(
title='Getting Started with Python',
slug='getting-started-python',
body='Python is a versatile language...',
author=alice,
published=True,
)
# READ
all_posts = Post.objects.all()
pub_posts = Post.objects.filter(published=True)
alice_posts = Post.objects.filter(author__username='alice').select_related('author')
# UPDATE
post.title = 'Getting Started with Python (Revised)'
post.save()
The QuerySet
Every ORM query returns a QuerySet: a lazy, chainable list of model objects. "Lazy" means Django doesn't run any SQL until you actually need the results, for example when you loop over it, slice it or call list() on it.
# run in: python manage.py shell
# Nothing hits the DB yet:
qs = Post.objects.filter(published=True)
# DB query fires here:
for post in qs:
print(post.title)
CRUD Operations
Here are create, read, update and delete side by side. Note the last two lines delete data; that's fine on a practice database.
# run in: python manage.py shell
from blog.models import Post
from django.contrib.auth.models import User
author = User.objects.get(username='alice')
# CREATE
post = Post.objects.create(
title='Hello Django',
slug='hello-django',
body='My first post.',
author=author,
published=True,
)
# READ: all
Post.objects.all()
# READ: filter
Post.objects.filter(published=True)
Post.objects.filter(author__username='alice') # <- double-underscore traverses FK
# READ: single record (raises DoesNotExist if missing)
post = Post.objects.get(slug='hello-django')
# UPDATE
post.title = 'Hello Django (Updated)'
post.save()
# Bulk UPDATE (no .save() needed)
Post.objects.filter(published=False).update(published=True)
# DELETE
post.delete()
Post.objects.filter(published=False).delete()
Lookups (Filtering)
| Lookup | Example | Meaning |
|---|---|---|
exact (default) |
filter(title='Hello') |
title = 'Hello' |
icontains |
filter(title__icontains='django') |
case-insensitive LIKE |
gt / gte / lt / lte |
filter(id__gt=5) |
>, >=, <, <= |
in |
filter(id__in=[1, 2, 3]) |
id IN (1, 2, 3) |
isnull |
filter(slug__isnull=True) |
IS NULL |
| Related field | filter(author__username='alice') |
JOIN through the foreign key |
select_related and prefetch_related
The N+1 problem: you load N posts with one query, then touching post.author runs one more query per post. These two methods fix it:
# run in: python manage.py shell
# BAD (the N+1 problem): one query per post to fetch the author
for post in Post.objects.all():
print(post.author.username)
# GOOD: single JOIN query
for post in Post.objects.select_related('author').all():
print(post.author.username)
# For many-to-many or reverse FK (e.g., all comments per post)
posts = Post.objects.prefetch_related('comments').all()
Rule of thumb: select_related for a foreign key you follow forwards (post -> author, one object). prefetch_related for the reverse side or many-to-many (post -> comments, many objects).
Exercise 3: Recent Python Posts
Write one QuerySet that returns the 5 most recently created published posts whose title contains "python" (any case). Chain the methods together.
Show solution
# run in: python manage.py shell
recent_python = (
Post.objects
.filter(published=True, title__icontains='python')
.order_by('-created_at')[:5]
)
print(list(recent_python))
Quick recap
Model.objectsgives you QuerySets:all(),filter(),exclude(),get(),order_by().- QuerySets are lazy and chainable. SQL runs only when you use the results.
- Double underscores do lookups (
title__icontains) and follow relations (author__username). - Use
select_related/prefetch_relatedto avoid the N+1 query problem.
4. Views & URL Patterns
Before we bring in DRF, let's see how far plain Django gets us. This helps you appreciate what DRF does for you in the next section.
Function-Based Views (FBVs)
A function-based view can handle several HTTP methods in one function. Check request.method to decide what to do.
# Illustration (blog/views.py)
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
import json
from .models import Post
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def post_list(request):
if request.method == 'GET':
posts = list(Post.objects.values('id', 'title', 'slug', 'published'))
return JsonResponse(posts, safe=False)
elif request.method == 'POST':
data = json.loads(request.body)
post = Post.objects.create(author=request.user, **data)
return JsonResponse({'id': post.id}, status=201)
Two things to notice:
@csrf_exempt: Django protects every POST, PUT and DELETE against CSRF attacks by default. That check expects a token that only Django's own HTML forms send, so a JSON request from curl gets403 Forbidden. We switch the check off for these demo views. DRF handles this properly for us later.author=request.user: every post needs an author.request.useris the logged-in user (orAnonymousUserif nobody is logged in, which is why the checkpoint below checks first).
URL Patterns
# Illustration (blog/urls.py)
from django.urls import path
from . import views
urlpatterns = [
path('posts/', views.post_list), # /api/posts/
path('posts/<int:pk>/', views.post_detail), # /api/posts/42/
]
URL converters:
| Converter | Matches | Example |
|---|---|---|
<int:pk> |
digits | /posts/42/ gives pk=42 |
<str:slug> |
anything except / |
/posts/hello-world/ |
<slug:slug> |
letters, digits, -, _ |
/posts/hello-world/ |
<uuid:id> |
a UUID | /items/550e8400-.../ |
get_object_or_404
# Illustration
from django.shortcuts import get_object_or_404
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk) # returns 404 if not found
return JsonResponse({'id': post.id, 'title': post.title, 'body': post.body})
Class-Based Views (CBVs)
With a class-based view, each HTTP method is its own method on the class. Django calls get() for GET requests, delete() for DELETE, and so on:
# Illustration
from django.views import View
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
class PostDetailView(View):
def get(self, request, pk):
post = get_object_or_404(Post, pk=pk)
return JsonResponse({'id': post.id, 'title': post.title})
def delete(self, request, pk):
post = get_object_or_404(Post, pk=pk)
post.delete()
return HttpResponse(status=204) # 204 means "no content", so no body
# Illustration (blog/urls.py)
path('posts/<int:pk>/', PostDetailView.as_view()),
Exercise 4: A Post Detail View
Write a post_detail function-based view that handles:
GET: return the post as JSONPUT: updatetitleandbodyfrom the JSON request bodyDELETE: delete the post
Use get_object_or_404.
Show solution
# add to: blog/views.py
@csrf_exempt
@require_http_methods(['GET', 'PUT', 'DELETE'])
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
if request.method == 'GET':
return JsonResponse({'id': post.id, 'title': post.title, 'body': post.body})
if request.method == 'PUT':
data = json.loads(request.body)
post.title = data.get('title', post.title)
post.body = data.get('body', post.body)
post.save()
return JsonResponse({'id': post.id, 'title': post.title})
post.delete() # DELETE
return HttpResponse(status=204)
Here is the full views.py and urls.py for this section. Compare how much code it takes; we'll replace all of it with a few lines of DRF in Section 6.
# file: blog/views.py (Section 4 checkpoint: plain Django views)
import json
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import Post
def health(request):
return JsonResponse({"status": "ok", "version": "1.0.0"})
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def post_list(request):
if request.method == 'GET':
posts = list(
Post.objects.filter(published=True)
.select_related('author')
.values('id', 'title', 'slug', 'author__username', 'created_at')
)
return JsonResponse(posts, safe=False)
elif request.method == 'POST':
if not request.user.is_authenticated:
return JsonResponse({'error': 'login required'}, status=401)
data = json.loads(request.body)
post = Post.objects.create(
title=data['title'],
slug=data['slug'],
body=data['body'],
author=request.user,
)
return JsonResponse({'id': post.id, 'title': post.title}, status=201)
@csrf_exempt
@require_http_methods(['GET', 'PUT', 'DELETE'])
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
if request.method == 'GET':
return JsonResponse({
'id': post.id,
'title': post.title,
'slug': post.slug,
'body': post.body,
'author': post.author.username,
'published': post.published,
})
elif request.method == 'PUT':
data = json.loads(request.body)
post.title = data.get('title', post.title)
post.body = data.get('body', post.body)
post.save()
return JsonResponse({'id': post.id, 'title': post.title})
elif request.method == 'DELETE':
post.delete()
return HttpResponse(status=204)
# file: blog/urls.py (Section 4 checkpoint)
from django.urls import path
from . import views
urlpatterns = [
path('health/', views.health),
path('posts/', views.post_list), # /api/posts/
path('posts/<int:pk>/', views.post_detail), # /api/posts/42/
]
What just happened? post_list only lists published posts, and it uses .values(...) to get plain dicts that JsonResponse can serialise. A model object can't be turned into JSON directly, which is why post_detail builds its dict by hand. safe=False is needed because we return a list rather than a dict.
Quick recap
- Function-based views branch on
request.method; class-based views have one method per HTTP verb. path('posts/<int:pk>/', view)captures part of the URL and passes it to the view.get_object_or_404saves you the try/except for missing objects.- Plain Django views mean manual JSON, manual validation and dealing with CSRF yourself. DRF fixes all three.
5. Django REST Framework: Serializers
Why DRF?
Plain Django views work, but you write the JSON parsing, validation and serialisation by hand. Django REST Framework (DRF) gives you:
- Serializers: declare the fields once and get validation and JSON conversion
- Generic views / ViewSets: full CRUD in a few lines
- Browsable API: a clickable HTML page for every endpoint
- Authentication & permissions: ready-made classes you plug in
ModelSerializer
A ModelSerializer builds its fields from a model. It plays the same role as the Pydantic ...Create / ...Response models in FastAPI, in one class:
# Illustration (blog/serializers.py)
from rest_framework import serializers
from .models import Post, Comment
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'body', 'published', 'author', 'created_at']
read_only_fields = ['id', 'created_at', 'author']
The inner Meta class connects the serializer to a model. fields lists the columns to include; read_only_fields are sent back to the client but can't be set by it.
Validation
Add validate_<field> methods for checks on one field, and validate for checks that involve several fields:
# Illustration (blog/serializers.py)
class PostSerializer(serializers.ModelSerializer):
title = serializers.CharField(min_length=5, max_length=200)
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'body', 'published', 'author', 'created_at']
read_only_fields = ['id', 'created_at', 'author']
def validate_slug(self, value):
"""Field-level validator."""
if ' ' in value:
raise serializers.ValidationError('Slug must not contain spaces.')
return value
def validate(self, data):
"""Object-level validator (runs after all fields pass)."""
if data.get('published') and not data.get('body'):
raise serializers.ValidationError('A published post must have a body.')
return data
When a check fails, DRF answers 400 Bad Request with the error message under the field name, for example {"slug": ["Slug must not contain spaces."]}.
Nested Serializers
A serializer can be used as a field inside another one. Here each post includes its full list of comments:
# Illustration (blog/serializers.py)
class CommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source='author.username', read_only=True)
class Meta:
model = Comment
fields = ['id', 'body', 'author_username', 'created_at']
read_only_fields = ['id', 'created_at', 'author_username']
class PostDetailSerializer(serializers.ModelSerializer):
comments = CommentSerializer(many=True, read_only=True) # inline nested objects
author_username = serializers.CharField(source='author.username', read_only=True)
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'body', 'published',
'author_username', 'created_at', 'comments']
source='author.username' reads a value through the relation, so the API shows "alice" instead of the author's id.
Exercise 5: Comment Count
Add a comment_count field to PostSerializer that returns how many comments each post has. Use serializers.SerializerMethodField and write a get_comment_count method.
Show solution
# add to: blog/serializers.py (inside PostSerializer)
comment_count = serializers.SerializerMethodField()
def get_comment_count(self, obj):
return obj.comments.count()
# and add 'comment_count' to Meta.fields
Here is the full serializers.py, including the exercise. PostDetailSerializer inherits from PostSerializer and just adds the comments, so the two never get out of step.
# file: blog/serializers.py (Section 5 checkpoint)
from rest_framework import serializers
from .models import Post, Comment
class CommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source='author.username', read_only=True)
class Meta:
model = Comment
fields = ['id', 'body', 'author_username', 'created_at']
read_only_fields = ['id', 'created_at', 'author_username']
class PostSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source='author.username', read_only=True)
comment_count = serializers.SerializerMethodField()
class Meta:
model = Post
fields = [
'id', 'title', 'slug', 'body', 'published',
'author_username', 'created_at', 'comment_count',
]
read_only_fields = ['id', 'created_at', 'author_username', 'comment_count']
def get_comment_count(self, obj):
return obj.comments.count()
def validate_slug(self, value):
if ' ' in value:
raise serializers.ValidationError('Slug must not contain spaces.')
return value
class PostDetailSerializer(PostSerializer):
"""Extends PostSerializer with full comment list for detail views."""
comments = CommentSerializer(many=True, read_only=True)
class Meta(PostSerializer.Meta):
fields = PostSerializer.Meta.fields + ['comments']
Quick recap
- A
ModelSerializerturns model objects into JSON and validates incoming JSON. Meta.fieldspicks the columns;read_only_fieldsare output-only.validate_<field>()for one field,validate()for several. Raiseserializers.ValidationError.source='author.username'andSerializerMethodFieldadd computed fields.
6. DRF Views & Routers
APIView
APIView is DRF's version of Django's View: one method per HTTP verb. On top of that, DRF parses JSON for you (request.data), turns Response objects into JSON, and runs authentication and permission checks.
# Illustration (blog/views.py)
from .models import Post
from .serializers import PostSerializer
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class PostListView(APIView):
def get(self, request):
posts = Post.objects.filter(published=True)
serializer = PostSerializer(posts, many=True)
return Response(serializer.data)
def post(self, request):
serializer = PostSerializer(data=request.data)
if serializer.is_valid():
serializer.save(author=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
ModelViewSet
A ViewSet puts all the operations for one resource in one class. A ModelViewSet gives you list, create, retrieve, update, partial_update and destroy without writing any of them:
# Illustration (blog/views.py)
from rest_framework import viewsets
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.select_related('author').all()
serializer_class = PostSerializer
DRF Router
Give the ViewSet to a DefaultRouter and it creates all the URL patterns:
# Illustration (blog/urls.py)
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register('posts', views.PostViewSet, basename='post')
urlpatterns = router.urls
That one register call creates:
| URL | Method | Action |
|---|---|---|
/api/posts/ |
GET | list |
/api/posts/ |
POST | create |
/api/posts/{pk}/ |
GET | retrieve |
/api/posts/{pk}/ |
PUT | update |
/api/posts/{pk}/ |
PATCH | partial_update |
/api/posts/{pk}/ |
DELETE | destroy |
Custom Actions with @action
For anything that isn't plain CRUD, add a method with @action. detail=True means it works on one object, so the URL includes the pk:
# Illustration (blog/views.py)
from rest_framework.decorators import action
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
@action(detail=True, methods=['post'], url_path='publish')
def publish(self, request, pk=None):
"""POST /api/posts/{pk}/publish/ marks a post as published."""
post = self.get_object()
post.published = True
post.save()
return Response({'status': 'published'})
Exercise 6: Unpublish
Add an @action called unpublish (detail=True, methods=['post']) that sets post.published = False. Both /publish/ and /unpublish/ should work on the same ViewSet.
Show solution
# add to: blog/views.py (inside PostViewSet)
@action(detail=True, methods=['post'], url_path='unpublish')
def unpublish(self, request, pk=None):
post = self.get_object()
post.published = False
post.save()
return Response({'status': 'unpublished'})
Section 6 Checkpoint
Replace blog/views.py and blog/urls.py with the two cells below. The comments live under each post (/api/posts/1/comments/), which needs the drf-nested-routers package from the install command.
Heads-up: until we add authentication in Section 7, you can read through the API but not create posts. perform_create saves self.request.user as the author, and an anonymous user can't be one, so a POST ends in a server error. That's exactly what the next section fixes.
# file: blog/views.py (Section 6 checkpoint: DRF ViewSets)
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Post, Comment
from .serializers import PostSerializer, PostDetailSerializer, CommentSerializer
def health(request):
return JsonResponse({"status": "ok", "version": "1.0.0"})
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.select_related('author').prefetch_related('comments').all()
def get_serializer_class(self):
# Use the detailed serializer (with comments) for single-object views
if self.action == 'retrieve':
return PostDetailSerializer
return PostSerializer
def perform_create(self, serializer):
# Automatically set the author to the authenticated user
serializer.save(author=self.request.user)
@action(detail=True, methods=['post'], url_path='publish')
def publish(self, request, pk=None):
post = self.get_object()
post.published = True
post.save()
return Response({'status': 'published'})
@action(detail=True, methods=['post'], url_path='unpublish')
def unpublish(self, request, pk=None):
post = self.get_object()
post.published = False
post.save()
return Response({'status': 'unpublished'})
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def get_queryset(self):
# Scope comments to the parent post
return Comment.objects.filter(
post_id=self.kwargs['post_pk']
).select_related('author').order_by('created_at') # oldest first
def perform_create(self, serializer):
post = get_object_or_404(Post, pk=self.kwargs['post_pk'])
serializer.save(author=self.request.user, post=post)
# file: blog/urls.py (Section 6 checkpoint: nested router)
from django.urls import path
from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers as nested_routers # pip install drf-nested-routers
from . import views
router = DefaultRouter()
router.register('posts', views.PostViewSet, basename='post')
# Nested: /api/posts/{post_pk}/comments/
posts_router = nested_routers.NestedDefaultRouter(router, 'posts', lookup='post')
posts_router.register('comments', views.CommentViewSet, basename='post-comments')
urlpatterns = [path('health/', views.health)] + router.urls + posts_router.urls
What just happened?
get_serializer_class()picks the serializer per action: the detailed one (with comments) for a single post, the short one for lists.perform_create()is a hook DRF calls when saving a new object. We use it to fill in fields the client must not choose, likeauthor.NestedDefaultRouter(router, 'posts', lookup='post')puts the comments URLs under a post, and passes the post's id to the view asself.kwargs['post_pk'].CommentViewSetusesget_object_or_404, so commenting on a post that doesn't exist gives404rather than a server error. It also sorts the comments (order_by('created_at'));Commenthas no default ordering, and paginating an unsorted list makes DRF print a warning.
Quick recap
APIView: one method per verb, withrequest.dataandResponsedoing the JSON work.ModelViewSet+DefaultRouter= full CRUD and all its URLs in a few lines.@action(detail=True, methods=['post'])adds extra endpoints like/publish/.- Override
get_queryset,get_serializer_classandperform_createto customise a ViewSet.
7. Authentication & Permissions
DRF Authentication Classes
| Class | How the client proves who it is |
|---|---|
SessionAuthentication |
Django session cookie (logging in through a browser) |
BasicAuthentication |
HTTP Basic: username and password on every request. Testing only. |
TokenAuthentication |
Authorization: Token <token> header |
JWTAuthentication (from djangorestframework-simplejwt) |
Authorization: Bearer <jwt> header |
We'll use TokenAuthentication. It's like the API key from the FastAPI notebook: a random string stored in the database, one per user.
Setting Up Token Authentication
Add this to the bottom of settings.py:
# add to: blogplatform/settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
Then run python manage.py migrate again. rest_framework.authtoken (which we added to INSTALLED_APPS in Section 1) brings its own table, authtoken_token.
DRF also ships a ready-made view that swaps a username and password for a token. Add it to blogplatform/urls.py:
# Illustration (blogplatform/urls.py; the final version is in Section 9)
from rest_framework.authtoken import views as drf_auth_views
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('blog.urls')),
path('api/auth/token/', drf_auth_views.obtain_auth_token), # POST {username, password}
]
Built-in Permission Classes
| Permission | Effect |
|---|---|
AllowAny |
Everyone, logged in or not |
IsAuthenticated |
Must send a valid token / session |
IsAuthenticatedOrReadOnly |
Anonymous users may only read (GET, HEAD, OPTIONS) |
IsAdminUser |
request.user.is_staff must be True |
Custom Object-Level Permission
Only a post's author should be able to edit or delete it. Built-in classes can't know that, so we write our own:
# file: blog/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOwnerOrReadOnly(BasePermission):
"""Write access only to the object's owner; read access for everyone."""
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
return obj.author == request.user
has_object_permission runs when the view loads one specific object (retrieve, update, delete and our publish action). SAFE_METHODS is the tuple ('GET', 'HEAD', 'OPTIONS'), the methods that don't change anything.
Apply it to the ViewSet. DRF checks every class in the list, and all of them must agree:
# Illustration (blog/views.py)
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .permissions import IsOwnerOrReadOnly
class PostViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
...
User Registration View
DRF has no sign-up endpoint, so we write a small one. It creates the user and gives back a token straight away:
# Illustration (blog/views.py; the full file is below)
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
from django.contrib.auth.models import User
class RegisterView(APIView):
permission_classes = [] # public endpoint
def post(self, request):
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response({'error': 'username and password required'}, status=400)
if User.objects.filter(username=username).exists():
return Response({'error': 'username already taken'}, status=400)
user = User.objects.create_user(username=username, password=password)
token = Token.objects.create(user=user)
return Response({'token': token.key}, status=201)
permission_classes = [] overrides the default from settings.py, so anyone may call it. Otherwise nobody could register without already having a token.
Exercise 7: Log Out
Add a DELETE /api/auth/token/revoke/ endpoint that deletes the current user's token, so they have to log in again. It must require IsAuthenticated.
Show solution
# add to: blog/views.py
from rest_framework.permissions import IsAuthenticated
class RevokeTokenView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request):
request.user.auth_token.delete()
return Response(status=204)
# add to: blogplatform/urls.py (inside urlpatterns)
# path('api/auth/token/revoke/', RevokeTokenView.as_view()),
Here is the complete views.py with everything from Sections 6 and 7. Besides the new auth views, CommentViewSet now also uses IsOwnerOrReadOnly. Without it, any logged-in user could delete anyone else's comment.
# file: blog/views.py (Section 7 checkpoint: full file)
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.authtoken.models import Token
from django.contrib.auth.models import User
from .models import Post, Comment
from .serializers import PostSerializer, PostDetailSerializer, CommentSerializer
from .permissions import IsOwnerOrReadOnly
def health(request):
return JsonResponse({"status": "ok", "version": "1.0.0"})
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.select_related('author').prefetch_related('comments').all()
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
def get_serializer_class(self):
if self.action == 'retrieve':
return PostDetailSerializer
return PostSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
@action(detail=True, methods=['post'], url_path='publish')
def publish(self, request, pk=None):
post = self.get_object()
post.published = True
post.save()
return Response({'status': 'published'})
@action(detail=True, methods=['post'], url_path='unpublish')
def unpublish(self, request, pk=None):
post = self.get_object()
post.published = False
post.save()
return Response({'status': 'unpublished'})
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def get_queryset(self):
return Comment.objects.filter(
post_id=self.kwargs['post_pk']
).select_related('author').order_by('created_at') # oldest first
def perform_create(self, serializer):
post = get_object_or_404(Post, pk=self.kwargs['post_pk'])
serializer.save(author=self.request.user, post=post)
class RegisterView(APIView):
permission_classes = [] # public: anyone can register
def post(self, request):
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response({'error': 'username and password required'}, status=400)
if User.objects.filter(username=username).exists():
return Response({'error': 'username already taken'}, status=400)
user = User.objects.create_user(username=username, password=password)
token = Token.objects.create(user=user)
return Response({'token': token.key}, status=201)
class RevokeTokenView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request):
request.user.auth_token.delete()
return Response(status=204)
Quick recap
- Authentication answers "who are you?"; permissions answer "are you allowed?".
- Set defaults in
REST_FRAMEWORKinsettings.py; override per view withpermission_classes. - Clients send
Authorization: Token <key>. Get the key from/api/auth/token/or/api/auth/register/. - Object-level rules ("only the author") go in
has_object_permission.
8. Django Admin
What Is the Admin?
Django comes with a complete web admin site at /admin/. You don't write any frontend code; you just register your models.
python manage.py createsuperuser # creates an admin user
python manage.py runserver
# open http://127.0.0.1:8000/admin/
Basic Registration
# Illustration (blog/admin.py, simplest version)
from django.contrib import admin
from .models import Post, Comment
admin.site.register(Post)
admin.site.register(Comment)
That's enough to get list, add, edit and delete pages for both models.
Customising the Admin
A ModelAdmin class controls the columns, filters, search box and more. An inline shows related objects (here, a post's comments) on the same page:
# Illustration (blog/admin.py)
from django.contrib import admin
from .models import Post, Comment
class CommentInline(admin.TabularInline):
model = Comment
extra = 0 # don't show blank rows by default
readonly_fields = ['author', 'created_at']
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'published', 'created_at']
list_filter = ['published', 'created_at']
search_fields = ['title', 'body', 'author__username']
prepopulated_fields = {'slug': ('title',)} # auto-fills slug from title
inlines = [CommentInline]
actions = ['make_published']
@admin.action(description='Mark selected posts as published')
def make_published(self, request, queryset):
queryset.update(published=True)
@admin.action(description=...) is the current way to name a bulk action. Older code sets make_published.short_description = ... after the function, which still works.
Common ModelAdmin Options
| Option | Effect |
|---|---|
list_display |
Columns shown in the list of objects |
list_filter |
Filter panel in the sidebar |
search_fields |
Adds a search box (uses LIKE queries) |
ordering |
Default sort order |
prepopulated_fields |
Fills one field from another as you type |
inlines |
Shows related objects on the same page |
actions |
Bulk actions in the list view |
readonly_fields |
Fields shown but not editable |
Exercise 8: Comment Admin
Register Comment with a CommentAdmin that shows post, author and created_at as columns, can be filtered by created_at, and can be searched by body and author__username. (Remove the plain admin.site.register(Comment) line, since a model can only be registered once.)
Show solution
# add to: blog/admin.py
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ['post', 'author', 'created_at']
list_filter = ['created_at']
search_fields = ['body', 'author__username']
# file: blog/admin.py (Section 8 checkpoint)
from django.contrib import admin
from .models import Post, Comment
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
readonly_fields = ['author', 'created_at']
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'published', 'created_at']
list_filter = ['published', 'created_at']
search_fields = ['title', 'body', 'author__username']
prepopulated_fields = {'slug': ('title',)}
inlines = [CommentInline]
actions = ['make_published']
@admin.action(description='Mark selected posts as published')
def make_published(self, request, queryset):
queryset.update(published=True)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ['post', 'author', 'created_at']
list_filter = ['created_at']
search_fields = ['body', 'author__username']
Quick recap
python manage.py createsuperuser, then log in at/admin/.@admin.register(Model)+ aModelAdminclass to customise the pages.list_display,list_filter,search_fieldsandinlinescover most needs.
9. Final settings.py & urls.py
Time to put all the pieces together in the project's config files.
blogplatform/settings.py
These are only the parts we changed. Leave everything else that startproject generated as it is. Compared with Section 7 we've added SessionAuthentication (so you can log in to the browsable API) and pagination.
# add to: blogplatform/settings.py (final version of the parts we changed)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# third-party
'rest_framework',
'rest_framework.authtoken',
# local
'blog',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
}
blogplatform/urls.py
# file: blogplatform/urls.py (final)
from django.contrib import admin
from django.urls import path, include
from rest_framework.authtoken import views as drf_auth_views
from blog.views import RegisterView, RevokeTokenView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('blog.urls')),
path('api/auth/register/', RegisterView.as_view()),
path('api/auth/token/', drf_auth_views.obtain_auth_token),
path('api/auth/token/revoke/', RevokeTokenView.as_view()),
path('api-auth/', include('rest_framework.urls')),
]
Testing with curl
# Register
curl -X POST http://127.0.0.1:8000/api/auth/register/ \
-H 'Content-Type: application/json' \
-d '{"username": "alice", "password": "secret123"}'
# -> {"token": "abc123..."}
# Create a post (logged in)
curl -X POST http://127.0.0.1:8000/api/posts/ \
-H 'Authorization: Token abc123...' \
-H 'Content-Type: application/json' \
-d '{"title": "Hello Django", "slug": "hello-django", "body": "My first post!"}'
# List posts (no login needed)
curl http://127.0.0.1:8000/api/posts/
# Publish a post (only its author can)
curl -X POST http://127.0.0.1:8000/api/posts/1/publish/ \
-H 'Authorization: Token abc123...'
# Log out
curl -X DELETE http://127.0.0.1:8000/api/auth/token/revoke/ \
-H 'Authorization: Token abc123...'
Because pagination is switched on, the list endpoint now wraps the posts: {"count": 1, "next": null, "previous": null, "results": [...]}.
You can also open http://127.0.0.1:8000/api/posts/ in the browser to use the browsable API. Log in with the link in the top-right corner (that's what api-auth/ is for).
Quick recap
INSTALLED_APPSlists DRF, the token app and our app;REST_FRAMEWORKsets auth, permission and pagination defaults.- The project's
urls.pymounts the admin, the app's API and the auth endpoints. - With pagination on, list responses look like
{count, next, previous, results}.
Extra Practice
Three small features for the finished project. Each needs only a few lines.
Practice 1: Filter Posts
Let clients filter the post list with ?published=true and ?author__username=alice, using the django-filter package (already in the install command).
Hint: add 'django_filters' to INSTALLED_APPS, set DEFAULT_FILTER_BACKENDS in REST_FRAMEWORK, and give PostViewSet a filterset_fields list.
Show solution
# add to: blogplatform/settings.py
INSTALLED_APPS += ['django_filters']
REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS'] = [
'django_filters.rest_framework.DjangoFilterBackend',
]
# add to: blog/views.py (inside PostViewSet)
filterset_fields = ['published', 'author__username']
Practice 2: Search and Sort
Add ?search=django (searches title and body) and ?ordering=title / ?ordering=-created_at to the post list, using DRF's SearchFilter and OrderingFilter.
Show solution
# add to: blog/views.py
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
# inside PostViewSet
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ['title', 'body']
ordering_fields = ['created_at', 'title']
# Setting filter_backends on the view replaces the default list from settings,
# which is why DjangoFilterBackend is listed again here.
Practice 3: My Posts
Add GET /api/posts/mine/ that returns only the logged-in user's posts, including unpublished ones. Anonymous users should get 401.
Hint: @action(detail=False, ...) makes an action on the collection, so there's no pk in the URL.
Show solution
# add to: blog/views.py (inside PostViewSet)
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def mine(self, request):
posts = self.get_queryset().filter(author=request.user)
page = self.paginate_queryset(posts)
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
Quick recap
filterset_fields(django-filter) gives exact-match filters with one line.SearchFilterandOrderingFilteradd?search=and?ordering=.@action(detail=False)adds endpoints on the collection, like/api/posts/mine/.
Summary & Cheat Sheet
| Topic | Remember this |
|---|---|
| Project vs app | A project is the configuration; apps hold the code. One project, many apps. |
| Models | Classes that map to tables. Fields are columns. |
| Migrations | makemigrations writes the change, migrate applies it. Always both. |
| ORM | Lazy, chainable QuerySets. select_related / prefetch_related avoid N+1 queries. |
| Views | Functions or classes that take an HttpRequest and return an HttpResponse. |
| DRF serializers | Declare fields once; get validation and JSON in both directions. |
ModelViewSet |
One class = full CRUD. Pair it with DefaultRouter for the URLs. |
@action |
Extra endpoints on a ViewSet (/publish/, /unpublish/). |
| Permissions | IsAuthenticatedOrReadOnly + a custom IsOwnerOrReadOnly protect writes. |
| Admin | Register models and customise with ModelAdmin for a free management UI. |
Useful Commands
django-admin startproject blogplatform # new project
python manage.py startapp blog # new app
python manage.py makemigrations # after changing models
python manage.py migrate # apply migrations
python manage.py createsuperuser # admin login
python manage.py shell # Python prompt with the project loaded
python manage.py runserver # dev server on port 8000
Further Reading
- Django documentation
- Django REST Framework documentation
- QuerySet API reference
- drf-nested-routers: nested URLs for related resources
- djangorestframework-simplejwt: JWT authentication for DRF
What's Next
The next notebook, Deploying Applications, shows how to take a project like this one (or the FastAPI one) off your laptop and run it on a real server.