Checkpoint: Build a Django REST API
Reviewed & published by Brayan K
A checkpoint is a pause-and-consolidate milestone where you recall and practise everything just covered, and a REST API is a set of URL endpoints that exchange JSON so other programs can read and write your data.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this checkpoint you'll review the whole second half of the course — pagination, sessions, uploads, email, caching, management commands, DRF auth, and deployment — then prove it by designing a small JSON API with a paginated list, a token-protected create endpoint, and a cache layer, followed by a short quiz.
What You'll Review in This Checkpoint
- Pagination: Paginator, page_obj, and paginate_by
- Sessions: request.session, expiry, and flushing
- File & image uploads, email, and the caching framework
- Custom management commands with BaseCommand
- DRF authentication, permissions, and throttling
- Deployment: DEBUG, ALLOWED_HOSTS, static files, WSGI
📚 What You've Learned
Paginator(queryset, per_page) splits a long list into pages. paginator.page(n) returns a Page with has_next, has_previous, and neighbour numbers for building links. You read the requested page from the ?page= query parameter and survive bad input with PageNotAnInteger and EmptyPage. A class-based ListView does it all with a single paginate_by attribute.
request.session behaves like a dictionary that survives across requests. You set, get with a default, and pop one-time values. SESSION_COOKIE_AGE controls the lifetime, the backend (db, cache, or signed cookie) controls where data lives, and flush() clears it on logout.
3. File & image uploads
FileField and ImageField store a path under MEDIA_ROOT and serve it under MEDIA_URL. Uploads arrive in request.FILES, so you bind the form with both request.POST and request.FILES and add enctype="multipart/form-data". upload_to routes files into subfolders.
send_mail fires a quick plain-text message; EmailMessage adds cc, bcc, and attachments; EmailMultiAlternatives sends HTML alongside text. EMAIL_BACKEND switches between the console backend in development and SMTP in production, and render_to_string builds bodies from templates.
5. The caching framework
The low-level API (cache.set, get, add, delete) stores values with a timeout. @cache_page caches a whole view and {% cache %} caches a template fragment. A timeout (TTL) makes entries expire, and unique cache keys keep one user's data from leaking to another.
6. Custom management commands
A Command class subclassing BaseCommand in app/management/commands/ adds your own manage.py task. add_arguments declares the CLI, handle does the work, and self.stdout.write with self.style.SUCCESS produces styled output you can schedule with cron.
7. DRF authentication & permissions
authentication_classes identify the caller (session, basic, or token), and TokenAuthentication reads an Authorization: Token … header. permission_classes like IsAuthenticated and IsAuthenticatedOrReadOnly decide access, a custom BasePermission enforces ownership, and throttling rate-limits clients with a 429.
Production means DEBUG=False, real domains in ALLOWED_HOSTS, and SECRET_KEY read from the environment. You collectstatic into STATIC_ROOT and serve it with WhiteNoise, run the app through gunicorn and WSGI, parse a DATABASE_URL, and audit with manage.py check --deploy.
1 Review: Paginating an API Response
A paginated API response carries the current page, the total number of pages, and the slice of results. The simulation below reproduces what Paginator plus a DRF serializer would return as JSON for a given page.
# A paginated JSON response: page number, total pages, and the slice of rows.
# This mirrors what Paginator + a DRF serializer produce for ?page=N.
import math
POSTS = [{"id": i, "title": f"Post {i}"} for i in range(1, 8)] # 7 posts
def paginated_response(page, per_page=3):
num_pages = max(1, math.ceil(len(POSTS) / per_page))
page = max(1, min(page, num_pages)) # clamp to a valid page
start = (page - 1) * per_page
return {
"page": page,
"num_pages": num_pages,
"count": len(POSTS),
"results": POSTS[start:start + per_page],
}
print(paginated_response(page=1))
print(paginated_response(page=3))
# EXPECTED:
# {'page': 1, 'num_pages': 3, 'count': 7, 'results': [{'id': 1, 'title': 'Post 1'}, {'id': 2, 'title': 'Post 2'}, {'id': 3, 'title': 'Post 3'}]}
# {'page': 3, 'num_pages': 3, 'count': 7, 'results': [{'id': 7, 'title': 'Post 7'}]}
# ✅ Expected output:
# {'page': 1, 'num_pages': 3, 'count': 7, 'results': [{'id': 1, 'title': 'Post 1'}, {'id': 2, 'title': 'Post 2'}, {'id': 3, 'title': 'Post 3'}]}
# {'page': 3, 'num_pages': 3, 'count': 7, 'results': [{'id': 7, 'title': 'Post 7'}]}2 Review: Token-Protecting a Create Endpoint
A create endpoint should reject anonymous callers. The example resolves the token to a user, returns 401 when it's missing or unknown, and otherwise creates the row — exactly the gate TokenAuthentication plus IsAuthenticated would enforce.
# Token-protected create: resolve the token to a user, return 401 if unknown,
# otherwise create the resource. Mirrors TokenAuthentication + IsAuthenticated.
TOKENS = {"abc123": "ada", "def456": "ben"}
posts = []
def create_post(auth_header, title):
scheme, _, key = auth_header.partition(" ")
user = TOKENS.get(key) if scheme == "Token" else None
if user is None:
return {"status": 401, "detail": "Authentication required"}
post = {"id": len(posts) + 1, "title": title, "author": user}
posts.append(post)
return {"status": 201, "created": post}
print(create_post("Token abc123", "Hello API"))
print(create_post("Token wrong", "Should fail"))
print(create_post("", "No header"))
# EXPECTED:
# {'status': 201, 'created': {'id': 1, 'title': 'Hello API', 'author': 'ada'}}
# {'status': 401, 'detail': 'Authentication required'}
# {'status': 401, 'detail': 'Authentication required'}
# ✅ Expected output:
# {'status': 201, 'created': {'id': 1, 'title': 'Hello API', 'author': 'ada'}}
# {'status': 401, 'detail': 'Authentication required'}
# {'status': 401, 'detail': 'Authentication required'}🏗️ Build Challenge: A Small JSON API
Time to put it together. Design a tiny Posts API that combines the three skills that pair up most often in real services:
- A paginated list endpoint that returns one page of posts.
- A token-protected create endpoint that rejects anonymous callers with 401.
- A cache layer that computes the total count once and reuses it.
The starter below has three ___ blanks — one per skill. Fill them in (per-page slice size, the "no user" value, and the cache key), then run to see the API respond. Try it yourself before opening the full solution.
# 🎯 BUILD CHALLENGE (starter): a tiny JSON API combining three skills you've
# learned — pagination, token-protected create, and a cache layer.
# Fill in the three ___ blanks, then run to see the API respond.
import math
POSTS = [{"id": i, "title": f"Post {i}"} for i in range(1, 8)] # 7 posts
TOKENS = {"abc123": "ada"}
CACHE = {}
def list_posts(page, per_page=3):
# PAGINATION: slice POSTS into pages of per_page
num_pages = math.ceil(len(POSTS) / per_page)
start = (page - 1) * per_page
items = POSTS[start:start + ___] # 👉 replace ___ with per_page
return {"page": page, "num_pages": num_pages, "results": items}
def create_post(token, title):
# TOKEN AUTH: reject if the token isn't known
user = TOKENS.get(token)
if user is ___: # 👉 replace ___ with None
return {"status": 401, "detail": "Unauthorized"}
new = {"id": len(POSTS) + 1, "title": title, "author": user}
POSTS.append(new)
return {"status": 201, "created": new}
def cached_count():
# CACHING: compute once, then serve from the cache
if "count" not in CACHE:
CACHE["count"] = len(POSTS)
return CACHE[___] # 👉 replace ___ with "count"
print(list_posts(page=1))
print(create_post("abc123", "Hello"))
print(create_post("wrong", "Nope"))
print("cached count:", cached_count())
# ✅ Expected output:
# {'page': 1, 'num_pages': 3, 'results': [{'id': 1, 'title': 'Post 1'}, {'id': 2, 'title': 'Post 2'}, {'id': 3, 'title': 'Post 3'}]}
# {'status': 201, 'created': {'id': 8, 'title': 'Hello', 'author': 'ada'}}
# {'status': 401, 'detail': 'Unauthorized'}
# cached count: 8A fuller version that wraps the API in a class, clamps out-of-range pages, supports two tokens, and invalidates the cached count on every write — the plain-Python core of a DRF view with pagination, TokenAuthentication, and the cache API:
# BUILD CHALLENGE (full solution): a small JSON API class that pages a list,
# requires a token to create, and caches the total count. This is the runnable
# core of a DRF view with pagination + TokenAuthentication + caching.
import math
class PostAPI:
def __init__(self):
self.posts = [{"id": i, "title": f"Post {i}"} for i in range(1, 8)]
self.tokens = {"abc123": "ada", "def456": "ben"}
self.cache = {}
# GET /posts/?page=N — paginated list
def list(self, page, per_page=3):
num_pages = max(1, math.ceil(len(self.posts) / per_page))
page = max(1, min(page, num_pages))
start = (page - 1) * per_page
results = self.posts[start:start + per_page]
return {
"page": page,
"num_pages": num_pages,
"count": len(self.posts),
"results": results,
}
# POST /posts/ — token-protected create
def create(self, token, title):
user = self.tokens.get(token)
if user is None:
return {"status": 401, "detail": "Unauthorized"}
self.cache.pop("count", None) # invalidate cache on write
new = {"id": len(self.posts) + 1, "title": title, "author": user}
self.posts.append(new)
return {"status": 201, "created": new}
# cached total count
def total(self):
if "count" not in self.cache:
self.cache["count"] = len(self.posts) # compute on miss
return self.cache["count"]
api = PostAPI()
print("page 1:", api.list(page=1))
print("page 3:", api.list(page=3))
print("count (cached):", api.total())
print("create ok:", api.create("abc123", "New Post"))
print("create bad token:", api.create("nope", "Blocked"))
print("count after create:", api.total())
# EXPECTED:
# page 1: {'page': 1, 'num_pages': 3, 'count': 7, 'results': [{'id': 1, 'title': 'Post 1'}, {'id': 2, 'title': 'Post 2'}, {'id': 3, 'title': 'Post 3'}]}
# page 3: {'page': 3, 'num_pages': 3, 'count': 7, 'results': [{'id': 7, 'title': 'Post 7'}]}
# count (cached): 7
# create ok: {'status': 201, 'created': {'id': 8, 'title': 'New Post', 'author': 'ada'}}
# create bad token: {'status': 401, 'detail': 'Unauthorized'}
# count after create: 8🎯 Your Turn: Sessions, Cache Timeout, Deploy Audit
Three more skills from this module, without a server: read a session value with a default so a first visit does not crash, watch a cached value expire once its timeout (its time-to-live) has passed, and run the same audit manage.py check --deploy performs on your settings. Each blank has a hint on its line; fill all three, then run and compare with the expected output.
# 🎯 YOUR TURN — three more ideas from this module in plain Python:
# the session dictionary, the cache API with a timeout, and a deploy audit.
# Fill in the three ___ blanks, then press Run.
# 1) SESSIONS: request.session behaves like a dict that survives from one
# request to the next. Read it with a default so a first-time visitor
# does not raise KeyError.
session = {}
def count_visit(session):
visits = session.___("visits", 0) + 1 # 👉 replace ___ with get
session["visits"] = visits
return visits
print("visit:", count_visit(session))
print("visit:", count_visit(session))
# 2) CACHING: cache.set(key, value, timeout) stores a value that expires.
# Once the timeout has passed, cache.get() is a miss and returns None.
CACHE = {}
NOW = 100 # a pretend clock, in seconds
def cache_set(key, value, timeout):
CACHE[key] = (value, NOW + timeout) # remember when it expires
def cache_get(key, now):
value, expires_at = CACHE.get(key, (None, 0))
return value if now < expires_at else None
cache_set("total_posts", 42, timeout=___) # 👉 replace ___ with 60
print("at t=130:", cache_get("total_posts", now=130))
print("at t=170:", cache_get("total_posts", now=170))
# 3) DEPLOYMENT: manage.py check --deploy flags unsafe settings before go-live.
settings = {"DEBUG": ___, "ALLOWED_HOSTS": ["example.com"], "SECRET_KEY": "from-env"} # 👉 replace ___ with False
warnings = []
if settings["DEBUG"]:
warnings.append("DEBUG must be False")
if not settings["ALLOWED_HOSTS"]:
warnings.append("ALLOWED_HOSTS is empty")
print("deploy warnings:", warnings)
# ✅ Expected output:
# visit: 1
# visit: 2
# at t=130: 42
# at t=170: None
# deploy warnings: []📝 Checkpoint Quiz
Try to answer each in your head first, then expand to check.
A: Set the paginate_by attribute to the number of items per page, e.g. paginate_by = 10. Django then builds the Paginator, reads ?page= from the URL, and adds page_obj, paginator, and is_paginated to the template context for you.
A: Read with a default — request.session.get("key", default) — so a missing key does not raise a KeyError. To clear everything on logout call request.session.flush(), which deletes the data and rotates the session key; Django's logout() view does this for you.
A: The HTML form needs enctype="multipart/form-data", and the view must bind the form to both request.POST and request.FILES, e.g. MyForm(request.POST, request.FILES). Miss either and the file silently never arrives.
A: timeout is the value's lifetime in seconds. Once it expires, a later cache.get returns None (a miss), so you recompute the value and store it again. It is the trade-off knob between fresher data (shorter) and faster pages (longer).
A: 401 Unauthorized means the request is not authenticated — log in or send a valid token. 403 Forbidden means the request is authenticated but the user is not allowed to perform that action. Authentication failures give 401; permission failures give 403.
A: Set DEBUG = False, list your real domains in ALLOWED_HOSTS, and read SECRET_KEY from the environment instead of hard-coding it. You also run collectstatic, serve the app via gunicorn/WSGI, and run manage.py check --deploy as a final audit.
❓ Frequently Asked Questions
Checkpoint complete — you can ship a real API!
You recapped pagination, sessions, uploads, email, caching, management commands, DRF authentication and permissions, and deployment — then designed a small JSON API with a paginated list, a token-protected create, and a cache layer, and worked the quiz.
Practice quiz
Which attribute on a class-based ListView turns on pagination?
- page_size
- paginate_by
- per_page
- page_count
Answer: paginate_by. Setting paginate_by = N makes Django build the Paginator, read ?page=, and add page_obj to the context.
In DRF, what does a 401 Unauthorized response indicate?
- The user is logged in but lacks permission for the action
- The request is not authenticated — log in or send a valid token
- The server crashed while handling the request
- The client sent malformed JSON
Answer: The request is not authenticated — log in or send a valid token. 401 means authentication failed or is missing; 403 means authenticated but not allowed.
Which HTTP header does TokenAuthentication read to identify the caller?
- Authorization: Token <key>
- X-Api-Key: <key>
- Cookie: token=<key>
- Bearer: <key>
Answer: Authorization: Token <key>. DRF's TokenAuthentication expects an 'Authorization: Token <key>' header.
What does the timeout argument to cache.set() control?
- How long the database query may run
- The value's lifetime in seconds before it expires
- How many keys the cache may hold
- The maximum size of the cached value
Answer: The value's lifetime in seconds before it expires. timeout is the TTL in seconds; after it expires, cache.get() returns None (a miss).
What two things must be present for an uploaded file to reach request.FILES?
- A GET request and a CSRF token
- enctype="multipart/form-data" on the form and binding the form with request.FILES
- A FileField default and null=True
- MEDIA_URL set and DEBUG=True
Answer: enctype="multipart/form-data" on the form and binding the form with request.FILES. The form needs multipart encoding and the view must pass request.FILES when binding the form.
Which DRF permission class allows anyone to read but requires login to write?
- IsAuthenticated
- AllowAny
- IsAuthenticatedOrReadOnly
- IsAdminUser
Answer: IsAuthenticatedOrReadOnly. IsAuthenticatedOrReadOnly permits safe (read) methods for anyone but requires auth for writes.
Which method on request.session safely reads a value that might be missing?
- request.session[key]
- request.session.get(key, default)
- request.session.fetch(key)
- request.session.read(key)
Answer: request.session.get(key, default). Using .get() with a default avoids a KeyError when the key is absent.
How do you clear a user's session data on logout?
- request.session.clear_all()
- request.session.reset()
- request.session.flush()
- del request.session
Answer: request.session.flush(). flush() deletes the session data and rotates the session key; Django's logout() calls it for you.
What does throttling add to a DRF API?
- It compresses JSON responses
- It rate-limits clients, returning 429 when they exceed the limit
- It caches every view automatically
- It validates request bodies
Answer: It rate-limits clients, returning 429 when they exceed the limit. Throttling limits request rates per client and responds with 429 Too Many Requests when exceeded.
Which command audits a project's settings for production readiness?
- manage.py check --deploy
- manage.py collectstatic
- manage.py migrate --check
- manage.py runserver --prod
Answer: manage.py check --deploy. manage.py check --deploy flags insecure settings like DEBUG=True before you go live.
Continue this course
- Previous: Deployment: WSGI, Static & Settings
- Next: Context Processors — Make variables available in every template automatically
- Quick reference: Django cheat sheet
Frequently asked questions
What does this checkpoint cover?
It consolidates the whole second half of the Django course: paginating long lists with Paginator, remembering visitors with the sessions framework, accepting file and image uploads, sending email, the caching framework, custom management commands, DRF authentication and permissions, and production deployment. The build challenge focuses on the three skills that combine most often in a real API — pagination, token-protected writes, and a cache layer.
Do I need a running Django project to do the build challenge?
No. Because a full Django project cannot boot inside the editor, the build challenge is written as a faithful plain-Python simulation that actually runs and prints output. It mirrors exactly what a DRF view with pagination, TokenAuthentication, and the cache API would do, so you can practise the logic and see it work without any server, database, or installed packages.
Is this checkpoint graded or required?
No. A checkpoint is a self-assessment milestone, not a graded exam. It exists so you can pause, recall what you learned across the recent lessons, and prove to yourself that the ideas stuck before moving on to the capstone. Work the build challenge and quiz at your own pace, and revisit any earlier lesson whose concept still feels shaky.
What comes after this checkpoint?
The capstone: a complete Blog application that pulls together everything from the entire course — models and the ORM, views, templates, forms, authentication, an admin, pagination, and an API. The checkpoint is your final tune-up before that larger project, so make sure the pagination, authentication, and caching ideas feel comfortable before you continue.