Django Module 2 – URLs, views, templates and static files
Views, requests and responses
Write Django views that read query strings, forms, headers and cookies, and answer with HTML, JSON, files and redirects under the right status code.
What you will learn
- Read query strings, form data, headers and cookies from an HttpRequest, with getlist() where a key repeats
- Choose the response class and the status code for HTML, JSON, a file download and a redirect
- Use render(), redirect(), get_object_or_404() and the HTTP method decorators in a view
- Apply Post/Redirect/Get so that refreshing a page never sends a form twice
Before you start
What you’ll learn · Before you start
What you will learn
- Read query strings, form data, headers and cookies from an HttpRequest, with getlist() where a key repeats
- Choose the response class and the status code for HTML, JSON, a file download and a redirect
- Use render(), redirect(), get_object_or_404() and the HTTP method decorators in a view
- Apply Post/Redirect/Get so that refreshing a page never sends a form twice
Before you start
On this page
A resident posts a notice, refreshes the page, confirms the browser’s question, and Nivas now holds the notice 2 times. The view decided that, as it decides every answer: what it reads from the request, which response it sends back, and which status code tells the browser what happened.
This lesson writes the notices views of Nivas: a list filtered by its query string, one notice as JSON, a form that saves and redirects, and a CSV download. You will read query strings, form fields, headers and cookies, pick the response class and the status code for each case, answer wrong methods with 405, and apply Post/Redirect/Get so that a refresh never posts twice.
A view reads one HttpRequest and returns one HttpResponse
A view: In Django, the function (or class) called for a URL: it receives the HttpRequest and the values the URL pattern captured, and returns an HttpResponse or raises an exception such as Http404. More in “How Django handles a request” is a function that Django calls with one
HttpRequest: The object Django builds for each request and passes to the view as its first argument: the method, the path, GET, POST, the headers, COOKIES and the body, and later attributes that middleware adds, such as user., plus the values its route captured, and that returns one HttpResponse.
Everything the browser sent is on that request object, already taken apart. Step through where each part of one
request lands:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The request line. The first line of the HTTP request holds the method and the target: GET /notices/?page=2&sort=newest. Django splits it: request.method is GET and request.path is /notices/, without the query string.
- The query string. Everything after the ? becomes request.GET, a QueryDict: request.GET.get('page') is the string '2', not a number. It is filled for every method, POST requests included.
- Headers. Each header lands in request.headers, which ignores case: 'Accept-Language' and 'accept-language' give the same 'hi-IN'. request.META has it too, as HTTP_ACCEPT_LANGUAGE.
- Cookies. The Cookie header is parsed into request.COOKIES, a plain dictionary of strings: request.COOKIES['theme'] is 'dark'.
- The body. The body is empty for this GET, so request.body is the bytes b"". For a form sent with POST, Django parses the fields into request.POST; any other body, JSON for example, stays as raw bytes in request.body alone.
This one-file app sends that request through Django’s test client, with a query string, a header and a cookie, and its view prints what it can read:
"""What a view can read from one request: Django's test client sends it, and the view prints what it finds."""
import django
from django.conf import settings
settings.configure(ALLOWED_HOSTS=['testserver'], ROOT_URLCONF=__name__)
django.setup()
from django.http import HttpResponse # noqa: E402
from django.test import Client # noqa: E402
from django.urls import path # noqa: E402
def notice_list(request):
print('request.method ', request.method)
print('request.path ', request.path)
print("request.GET.get('page') ", repr(request.GET.get('page')))
print("request.GET['sort'] ", repr(request.GET['sort']))
print("request.headers['Accept-Language']", repr(request.headers['Accept-Language']))
print("request.headers['accept-language']", repr(request.headers['accept-language']))
print("request.META['HTTP_ACCEPT_LANGUAGE']", repr(request.META['HTTP_ACCEPT_LANGUAGE']))
print("request.COOKIES['theme']", repr(request.COOKIES['theme']))
print('request.body ', repr(request.body))
return HttpResponse('ok')
urlpatterns = [path('notices/', notice_list)]
client = Client()
client.cookies['theme'] = 'dark'
client.get('/notices/', {'page': '2', 'sort': 'newest'}, headers={'Accept-Language': 'hi-IN'}) Output
request.method GET
request.path /notices/
request.GET.get('page') '2'
request.GET['sort'] 'newest'
request.headers['Accept-Language'] 'hi-IN'
request.headers['accept-language'] 'hi-IN'
request.META['HTTP_ACCEPT_LANGUAGE'] 'hi-IN'
request.COOKIES['theme'] 'dark'
request.body b''
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 request_parts.py
Three details decide how you read a request. request.path is the path alone; the query string is parsed into
request.GET, whatever the method. request.headers ignores the case of a header’s name, which HTTP itself does, so
both spellings in the run found 'hi-IN', and it takes _ for -, so a template can read
request.headers.accept_language; request.META holds the same header renamed HTTP_ACCEPT_LANGUAGE, beside server
details such as the client’s address. And nothing is converted for you: the page is the string '2', the cookie the
string 'dark' and the body raw bytes, because a request is text from a client you do not control.
Every value in a request comes from the client
A query string, a form field, a header or a cookie can hold anything anyone types, whatever your own pages send. Convert each value you use, check it against what you allow, and fall back to a safe default, as the exercise of this lesson does with the filters of the notices list.
request.GET and request.POST hold several values per key
A query string may repeat a key. A filter for two categories arrives as
?category=water&category=lift, and an HTML form with several checkboxes of the same name posts the same way. So
request.GET and request.POST are each a QueryDict: Django's dictionary for a query string or form data, as in request.GET and request.POST: one key can hold several values, getlist() returns them all, and the copies a request gives a view cannot be changed., a dictionary that keeps every value
of a key:
"""request.GET and request.POST are QueryDicts: a key can hold several values, and in a request they are read-only."""
import django
from django.conf import settings
settings.configure()
django.setup()
from django.http import QueryDict # noqa: E402
from django.utils.datastructures import MultiValueDictKeyError # noqa: E402
query = QueryDict('category=water&category=lift&page=2') # what ?category=water&category=lift&page=2 gives
print("query['category'] ", repr(query['category']))
print("query.get('category') ", repr(query.get('category')))
print("query.getlist('category')", repr(query.getlist('category')))
print("query.get('page') ", repr(query.get('page')), '(always a string)')
print("query.get('sort', 'new') ", repr(query.get('sort', 'new')))
print("query.getlist('sort') ", repr(query.getlist('sort')))
try:
query['sort']
except MultiValueDictKeyError as error:
print("query['sort'] MultiValueDictKeyError:", error)
try:
query['page'] = '3'
except AttributeError as error:
print("query['page'] = '3' AttributeError:", error)
changed = query.copy()
changed['page'] = '3'
print('query.copy() with page 3 ', changed.urlencode()) Output
query['category'] 'lift'
query.get('category') 'lift'
query.getlist('category') ['water', 'lift']
query.get('page') '2' (always a string)
query.get('sort', 'new') 'new'
query.getlist('sort') []
query['sort'] MultiValueDictKeyError: 'sort'
query['page'] = '3' AttributeError: This QueryDict instance is immutable
query.copy() with page 3 category=water&category=lift&page=3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 querydict.py
The first three lines are the trap. Indexing and get() give the last value, 'lift', so a view that reads
request.GET['category'] silently drops water. getlist() returns every value in order, and [] for a key that is
missing, which is why the notices list reads its categories with it. For a key that may be missing, use get() with a
default; indexing raises MultiValueDictKeyError, and a view that does not catch it answers with a 500 error.
The last lines show that the QueryDicts of a request are read-only. To build a link with another page number, change a
copy, request.GET.copy(), and call urlencode() on it.
request.POST has one more limit: Django fills it only for the bodies HTML forms send, application/x-www-form-urlencoded
and multipart/form-data. A script that posts JSON finds request.POST empty; the JSON is in request.body, as
bytes, for the view to parse with json.loads(). A later section shows a view tripping over exactly this.
Reading a repeated key with get()
request.GET.get('category') for ?category=water&category=lift returns 'lift' and loses water. Read keys that
may repeat with request.GET.getlist('category'), and keys with one value with get('page', '1').
Checkpoint
Check: QueryDicts with several values per key
The response class and the status code say what came back
A view chooses two things for its answer: a response class, which sets the body and the Content-Type, and a
status code: The three-digit number at the start of an HTTP response that says what happened, such as 200 OK, 201 Created, 302 Found for a redirect, 404 Not Found or 405 Method Not Allowed., which tells the client what happened before it reads a word of the body.
This program builds the classes a view uses most, and prints the status line and the headers of each:
"""The response classes a view returns, and what each one tells the client: the status and the headers."""
import io
import django
from django.conf import settings
settings.configure()
django.setup()
from django.http import ( # noqa: E402
FileResponse,
HttpResponse,
HttpResponseNotAllowed,
HttpResponsePermanentRedirect,
HttpResponseRedirect,
JsonResponse,
)
def show(code, response):
headers = ', '.join(f'{name}: {value}' for name, value in response.headers.items() if name != 'Content-Length')
print(f'{code}\n {response.status_code} {response.reason_phrase}; {headers}')
show("HttpResponse('<p>Saved</p>')", HttpResponse('<p>Saved</p>'))
show("HttpResponse(status=201)", HttpResponse(status=201))
show("HttpResponse(status=204)", HttpResponse(status=204))
show("JsonResponse({'id': 7})", JsonResponse({'id': 7}))
show("JsonResponse([7, 8], safe=False)", JsonResponse([7, 8], safe=False))
try:
JsonResponse([7, 8])
except TypeError as error:
print(f'JsonResponse([7, 8])\n TypeError: {error}')
show("HttpResponseRedirect('/notices/7/')", HttpResponseRedirect('/notices/7/'))
show("HttpResponseRedirect('/notices/7/', preserve_request=True)", HttpResponseRedirect('/notices/7/', preserve_request=True))
show("HttpResponsePermanentRedirect('/board/7/')", HttpResponsePermanentRedirect('/board/7/'))
show("HttpResponseNotAllowed(['GET', 'POST'])", HttpResponseNotAllowed(['GET', 'POST']))
show("FileResponse(..., as_attachment=True, filename='notices.csv')",
FileResponse(io.BytesIO(b'id,title\n'), as_attachment=True, filename='notices.csv')) Output
HttpResponse('<p>Saved</p>')
200 OK; Content-Type: text/html; charset=utf-8
HttpResponse(status=201)
201 Created; Content-Type: text/html; charset=utf-8
HttpResponse(status=204)
204 No Content; Content-Type: text/html; charset=utf-8
JsonResponse({'id': 7})
200 OK; Content-Type: application/json
JsonResponse([7, 8], safe=False)
200 OK; Content-Type: application/json
JsonResponse([7, 8])
TypeError: In order to allow non-dict objects to be serialized set the safe parameter to False.
HttpResponseRedirect('/notices/7/')
302 Found; Content-Type: text/html; charset=utf-8, Location: /notices/7/
HttpResponseRedirect('/notices/7/', preserve_request=True)
307 Temporary Redirect; Content-Type: text/html; charset=utf-8, Location: /notices/7/
HttpResponsePermanentRedirect('/board/7/')
301 Moved Permanently; Content-Type: text/html; charset=utf-8, Location: /board/7/
HttpResponseNotAllowed(['GET', 'POST'])
405 Method Not Allowed; Content-Type: text/html; charset=utf-8, Allow: GET, POST
FileResponse(..., as_attachment=True, filename='notices.csv')
200 OK; Content-Type: text/csv, Content-Disposition: attachment; filename="notices.csv"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 responses.py
| Class | Use it for | Status |
|---|---|---|
HttpResponse |
HTML or text you built | 200, or status=201, 204 … |
JsonResponse |
JSON: a dict, or anything with safe=False |
200, or status=… |
FileResponse |
a file; as_attachment=True downloads it |
200 |
HttpResponseRedirect |
send the browser elsewhere | 302, or 307 |
HttpResponsePermanentRedirect |
an address that moved for good | 301, or 308 |
HttpResponseNotAllowed |
a method the resource does not take | 405, with Allow |
The status codes of RFC 9110 are part of the answer: 201 says something was created, 204 that the request worked with
nothing to send back, 404 that the resource is missing, 409 that the request conflicts with its current state, and
422 that the data is well formed but fails validation. Two details from the run: JsonResponse([7, 8]) raised a
TypeError, because on 6.1 it accepts only a dict unless you pass safe=False; and preserve_request=True turned the
redirect from 302 into 307, which asks the browser to repeat the same method and body at the new address.
Version note
The release notes of Django 6.2, still in development, deprecate the safe parameter of JsonResponse and make
False its default, so JsonResponse([7, 8]) will work there. On 6.1, keep safe=False for anything but a dict.
An error page is an answer too. With DEBUG = False, Django’s own 404 and 500 pages show no code and no settings;
keep it that way in your own views, and never put an exception’s text or a stack trace in a response. OWASP’s Error
Handling Cheat Sheet explains why: such details reveal the software and the versions behind a site, which helps an
attacker plan, so log them on the server and show the visitor a short message.
Shortcuts render a page, redirect, and turn a missing row into a 404
Three shortcuts from django.shortcuts do the work most views repeat. render(request, template, context, status=…)
fills a template and wraps it in an HttpResponse. redirect() takes a route’s name and its values, reverses them as
the previous lesson did, and returns a 302. get_object_or_404(Notice, pk=pk) fetches one row, or raises Http404,
which Django turns into a 404 response. The notices views of Nivas use all three; the Notice model has three fields,
and the module on models explains the rest:
notices/views.py · nivas/notices/views.py
import csv
import io
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods, require_safe
from .models import Notice
CATEGORIES = ['water', 'lift', 'parking', 'security']
@require_safe
def notice_list(request):
# ?category=water&category=lift: getlist() keeps every value; unknown categories are ignored.
chosen = [c for c in request.GET.getlist('category') if c in CATEGORIES]
notices = Notice.objects.order_by('pk')
if chosen:
notices = notices.filter(category__in=chosen)
return render(request, 'notices/list.html', {'notices': notices, 'chosen': chosen})
@require_safe
def notice_detail(request, pk):
notice = get_object_or_404(Notice, pk=pk)
return JsonResponse({'id': notice.pk, 'title': notice.title, 'category': notice.category})
@require_http_methods(['GET', 'POST'])
def notice_create(request):
if request.method == 'POST':
title = request.POST.get('title', '').strip()
category = request.POST.get('category', '')
if title and category in CATEGORIES:
notice = Notice.objects.create(title=title, category=category)
return redirect('notices:detail', pk=notice.pk) # Post/Redirect/Get
context = {'categories': CATEGORIES, 'error': 'Give a title and one of the categories.'}
return render(request, 'notices/form.html', context, status=422)
return render(request, 'notices/form.html', {'categories': CATEGORIES})
@require_safe
def notice_export(request):
rows = io.StringIO()
writer = csv.writer(rows)
writer.writerow(['id', 'title', 'category'])
for notice in Notice.objects.order_by('pk'):
writer.writerow([notice.pk, notice.title, notice.category])
return FileResponse(io.BytesIO(rows.getvalue().encode()), as_attachment=True, filename='notices.csv')
def flat_notices(request, code):
return HttpResponse(f'Notices for flat {code}, block {code.block}') notices/models.py · nivas/notices/models.py
from django.db import models
# The module on models explains fields and migrations. For this lesson a notice has three fields.
class Notice(models.Model):
title = models.CharField(max_length=120)
category = models.CharField(max_length=20)
body = models.TextField(blank=True)
def __str__(self):
return self.title This program sends the requests a browser and a script would send, through the test client, against a test database with three notices in it, and prints what came back:
"""Send the requests a browser and a script would send to the notices views, through Django's test client, and
print what came back: the status, the headers that matter and the body.
Run it next to manage.py. It makes a test database in memory, as manage.py test does, with three notices in it.
"""
import json
import logging
import os
import re
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.test import Client # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL) # the 404, 405 and 422 below are expected
from notices.models import Notice # noqa: E402
for pk, title, category in [(7, 'Water tank cleaning on Sunday', 'water'),
(8, 'Lift in block B under repair', 'lift'),
(9, 'Visitors park in P2 this week', 'parking')]:
Notice.objects.create(pk=pk, title=title, category=category)
client = Client()
def show(request_line, response, *headers):
print(request_line)
line = f' {response.status_code} {response.reason_phrase}'
for name in headers:
line += f', {name}: {response[name]}'
print(line)
response = client.get('/notices/', {'category': ['water', 'lift']})
show('GET /notices/?category=water&category=lift', response, 'Content-Type')
for item in re.findall(r'<li>(.*?)</li>', response.text):
print(f' {item}')
response = client.get('/notices/7/')
show('GET /notices/7/', response, 'Content-Type')
print(f' {response.text}')
show('GET /notices/70/', client.get('/notices/70/'))
response = client.post('/notices/new/', {'title': 'Generator test at 4 pm', 'category': 'security'})
show('POST /notices/new/ with title=Generator test at 4 pm and category=security', response, 'Location')
response = client.get(response['Location'])
show('GET /notices/10/, the page the redirect points to', response)
print(f' {response.text}')
response = client.post('/notices/new/', json.dumps({'title': 'Water off at noon', 'category': 'water'}),
content_type='application/json')
show('POST /notices/new/ with the same kind of data as JSON', response)
print(' ' + re.search(r'role="alert">(.*?)<', response.text).group(1))
show('PUT /notices/new/', client.put('/notices/new/'), 'Allow')
show('HEAD /notices/7/, as a link checker sends it', client.head('/notices/7/'))
show('DELETE /notices/7/', client.delete('/notices/7/'), 'Allow')
response = client.get('/notices/export/')
show('GET /notices/export/', response, 'Content-Type', 'Content-Disposition')
for row in b''.join(response.streaming_content).decode().splitlines():
print(f' {row}') Output
GET /notices/?category=water&category=lift
200 OK, Content-Type: text/html; charset=utf-8
Water tank cleaning on Sunday (water)
Lift in block B under repair (lift)
GET /notices/7/
200 OK, Content-Type: application/json
{"id": 7, "title": "Water tank cleaning on Sunday", "category": "water"}
GET /notices/70/
404 Not Found
POST /notices/new/ with title=Generator test at 4 pm and category=security
302 Found, Location: /notices/10/
GET /notices/10/, the page the redirect points to
200 OK
{"id": 10, "title": "Generator test at 4 pm", "category": "security"}
POST /notices/new/ with the same kind of data as JSON
422 Unprocessable Content
Give a title and one of the categories.
PUT /notices/new/
405 Method Not Allowed, Allow: GET, POST
HEAD /notices/7/, as a link checker sends it
200 OK
DELETE /notices/7/
405 Method Not Allowed, Allow: GET, HEAD
GET /notices/export/
200 OK, Content-Type: text/csv, Content-Disposition: attachment; filename="notices.csv"
id,title,category
7,Water tank cleaning on Sunday,water
8,Lift in block B under repair,lift
9,Visitors park in P2 this week,parking
10,Generator test at 4 pm,security
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_views.py
Read it from the top. The list read both categories with getlist() and rendered its template, text/html. Notice 7
came back as JSON, and notice 70, which does not exist, as a 404 from get_object_or_404; a bare
Notice.objects.get() would have raised DoesNotExist and answered 500. The form post that worked got a redirect,
the subject of the last section. The same data posted as JSON got 422 with the form’s error, because request.POST
was empty, as the previous section warned. These requests carry no CSRF token because the test client skips the
check that CsrfViewMiddleware makes on every POST, PUT and DELETE; from a real script without a token, they would
get 403 before any view ran. The export came back as text/csv with Content-Disposition: attachment, so a browser saves it as notices.csv.
Key idea
A view returns one response, and its class and status code are part of the answer: 404 for a missing row, 422 for data that fails its checks, a redirect after a success.
Checkpoint
Check: Response classes and status codes · render, redirect and get_object_or_404
A method decorator answers the wrong method with 405
Each notices view serves only the methods it was written for, and says so in one line, above its definition. Django’s
decorators in django.views.decorators.http check the method before the view’s code runs and answer any other method
with HttpResponseNotAllowed, a 405 that lists the methods allowed in its Allow header:
@require_http_methods(['GET', 'POST'])onnotice_create, which shows the form and accepts it;@require_safeon the views that only read, which allows GET and HEAD;@require_GETand@require_POSTfor one method each.
The transcript above shows both kinds of refusal for requests that reached the views, and the HEAD request a link
checker would send:
| Request (and the view’s decorator) | Answer |
|---|---|
PUT /notices/new/ (require_http_methods) |
405, Allow: GET, POST |
DELETE /notices/7/ (require_safe) |
405, Allow: GET, HEAD |
HEAD /notices/7/ (require_safe) |
200, as for a GET |
The HEAD row is why the read-only views use require_safe rather than require_GET: the decorators page suggests
it, because tools such as link checkers rely on HEAD.
Without a decorator, a view that tests if request.method == 'POST': and treats everything else as a GET would answer
a DELETE with the list page and 200, telling a script that the delete worked.
Post/Redirect/Get keeps a refresh from sending a form twice
A browser’s refresh button repeats the last request it made. When a view answers a successful POST with a page, that last request is the POST, and refresh sends the form again. Post/Redirect/Get: Answering a successful form POST with a redirect to a page fetched with GET, so that the browser's last request is the GET and refreshing the page never sends the form again. answers the POST with a redirect instead, so the browser’s last request becomes a GET. Step through both, with the redirect and without it:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The form is sent. A resident fills in the form and presses Publish. The browser sends POST /notices/new/ with title and category.
- The notice is saved. The view checks the fields and saves the notice. The database gives it the number 10.
- The answer is a redirect. Instead of a page, the view answers 302 Found with Location: /notices/10/, the address of the new notice.
- The browser asks again. The browser follows the redirect with a GET. The page it shows now came from GET /notices/10/, and that is the last request it remembers.
- Refresh is harmless. The resident presses refresh. The browser repeats GET /notices/10/, which reads the notice and saves nothing.
- Without the redirect. Now a view that answers the POST with a 200 page itself. The last request the browser remembers is the POST, so refresh asks to confirm form resubmission, and sends it again.
- Saved twice. The view cannot tell the second POST from the first, and saves a second copy. The recorded run of prg.py counted 2 notices for one submission.
This program plays the browser, refresh button included, against two views that save the same notice, one that answers the POST itself and one that redirects:
"""Post/Redirect/Get. A resident submits a notice, then presses refresh. The browser repeats its last request: without
a redirect that is the POST, so the notice is saved twice; with one it is a GET, which changes nothing."""
import django
from django.conf import settings
settings.configure(ALLOWED_HOSTS=['testserver'], ROOT_URLCONF=__name__)
django.setup()
from django.http import HttpResponse # noqa: E402
from django.shortcuts import redirect # noqa: E402
from django.test import Client # noqa: E402
from django.urls import path # noqa: E402
saved = []
def create_and_answer(request):
saved.append(request.POST['title'])
return HttpResponse(f'Saved. {len(saved)} notice(s).')
def create_and_redirect(request):
saved.append(request.POST['title'])
return redirect('saved')
def saved_page(request):
return HttpResponse(f'{len(saved)} notice(s).')
urlpatterns = [
path('answer/new/', create_and_answer),
path('redirect/new/', create_and_redirect),
path('saved/', saved_page, name='saved'),
]
class Browser:
"""Remembers the last request it made, as a browser's refresh button does."""
def __init__(self):
self.client = Client()
self.last = None
def request(self, method, url, data=None):
response = getattr(self.client, method)(url, data or {})
self.last = (method, url, data)
print(f' {method.upper()} {url} -> {response.status_code}', end='')
if response.status_code == 302: # a browser follows a redirect with a GET
print(f', then GET {response["Location"]}', end='')
response = self.client.get(response['Location'])
self.last = ('get', response.wsgi_request.path, None)
print(f': {response.text}')
def refresh(self):
print(' refresh:', end='')
self.request(*self.last)
for url in ['/answer/new/', '/redirect/new/']:
saved.clear()
print('The view answers the POST itself' if url.startswith('/answer') else 'The view answers the POST with a redirect')
browser = Browser()
browser.request('post', url, {'title': 'Water tank cleaning on Sunday'})
browser.refresh()
print(f' notices saved: {len(saved)}') Output
The view answers the POST itself POST /answer/new/ -> 200: Saved. 1 notice(s). refresh: POST /answer/new/ -> 200: Saved. 2 notice(s). notices saved: 2 The view answers the POST with a redirect POST /redirect/new/ -> 302, then GET /saved/: 1 notice(s). refresh: GET /saved/ -> 200: 1 notice(s). notices saved: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 prg.py
Without the redirect, one submission and one refresh saved 2 notices; with it, the refresh repeated GET /saved/ and
the count stayed at 1. The notices form of Nivas does the same with redirect('notices:detail', pk=notice.pk), which
the transcript showed as 302 Found with Location: /notices/10/. Redirect only after a success: a failed form is
shown again with its errors and a status such as 422, so nothing was saved that a refresh could repeat.
Answering a successful POST with render()
return render(request, 'notices/done.html', …) after saving leaves the POST as the browser’s last request.
Return redirect('notices:detail', pk=notice.pk) instead, and show the confirmation on that page.
Post/Redirect/Get stops the duplicate that a refresh causes. It cannot stop two POSTs that leave before any answer comes back, such as a double click on a slow connection; that needs the server to recognise a request it has already handled, which the interview questions of this lesson take further.
Checkpoint
Check: Method decorators and 405 · Post/Redirect/Get
Practise: filters you can trust
The notices list reads its filters from the query string, where anyone can type anything: a status that does not
exist, a page number of minus 3, a flat code twice. Write parse_filters(params), which turns a QueryDict into
filters with allowed values and safe defaults. Its seven sample tests feed it hand-made query strings through an
object with a getlist() method, as request.GET has one, and they run in your browser:
Exercise · Easy · Python
Turn a query string into filters you can trust
The notices list of Nivas takes its filters from the query string, such as ?status=open&status=closed&flat=B-1203&page=2. Anyone can type any query string, so write parse_filters(params), which turns it into a dictionary the view can trust:
'status': every value of thestatuskey that is'open','closed'or'pending', in the order they first appear and each only once. When no value is valid,['open'].'flat': the lastflatvalue, if it is a flat code: a capital letter from A to F, a hyphen and three or four digits, such asB-1203. OtherwiseNone.'page': the lastpagevalue as anint, if it is a whole number of 1 or more written in the digits 0 to 9. Otherwise1.- Every other key is ignored.
params works like request.GET: params.getlist(key) returns all the values of a key as strings, in order, and [] when the key is missing. The sample tests pass a small stand-in with that one method. For the example above, parse_filters returns {'status': ['open', 'closed'], 'flat': 'B-1203', 'page': 2}.
Starter code · filters.py
def parse_filters(params):
"""Turn a QueryDict-like object into {'status': [...], 'flat': ..., 'page': ...} with safe values."""
return {'status': params.getlist('status'), 'flat': None, 'page': 1} The sample tests · test_filters.py
from urllib.parse import parse_qsl
from filters import parse_filters
class Params:
"""A stand-in for request.GET: getlist() returns every value of a key, as QueryDict does."""
def __init__(self, query=''):
self.pairs = parse_qsl(query, keep_blank_values=True)
def getlist(self, key):
return [value for name, value in self.pairs if name == key]
def test_the_example_query():
params = Params('status=open&status=closed&flat=B-1203&page=2')
assert parse_filters(params) == {'status': ['open', 'closed'], 'flat': 'B-1203', 'page': 2}
def test_an_empty_query_gives_the_defaults():
assert parse_filters(Params('')) == {'status': ['open'], 'flat': None, 'page': 1}
def test_unknown_statuses_are_dropped_and_repeats_kept_once():
assert parse_filters(Params('status=open&status=deleted&status=open&status=pending'))['status'] == ['open', 'pending']
def test_no_valid_status_falls_back_to_open():
assert parse_filters(Params('status=archived&status='))['status'] == ['open']
def test_a_flat_that_is_not_a_flat_code_is_none():
for flat in ['b-1203', 'G-1203', 'B1203', 'B-12', 'B-12034', '']:
assert parse_filters(Params(f'flat={flat}'))['flat'] is None, flat
def test_a_page_that_is_not_a_whole_number_of_one_or_more_is_one():
for page in ['0', '-3', 'two', '2.5', '']:
assert parse_filters(Params(f'page={page}'))['page'] == 1, page
assert parse_filters(Params('page=007'))['page'] == 7
def test_the_last_value_counts_and_other_keys_are_ignored():
params = Params('page=2&page=5&flat=A-101&flat=C-2201&sort=newest')
assert parse_filters(params) == {'status': ['open'], 'flat': 'C-2201', 'page': 5} A hint
Read each key with params.getlist(key), never assume it has exactly one value. For status, walk through the list and keep a value only if it is allowed and not kept already. For flat and page, the value that counts is the last one, values[-1], when the list is not empty; check it with re.fullmatch() before you use it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then fifteen questions on views put the six ideas of this lesson to work, from the parts of a request to Post/Redirect/Get. The self-check opens with the idea your earlier answers left weakest, and each choice explains itself:
Practice that adapts to you
Practise this lesson
15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): For ?category=water&category=lift, why does request.GET['category'] give only 'lift', and how do you get both?
A query string may repeat a key, so request.GET is a QueryDict, which keeps every value of a key in order.
Indexing it, like get(), returns the last value, 'lift', for code that expects one value per key. getlist('category')
returns all of them, ['water', 'lift'], and an empty list when the key is missing. The same holds for request.POST,
where checkboxes that share a name post several values. Read keys that may repeat with getlist(), and check each
value against the ones you allow.
Two design questions of this module start from these views: a payment that a refresh or a double click sends twice, and a JSON endpoint written without an API framework.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
Residents pay their monthly maintenance through a Django form. Some refresh the confirmation page, see "Confirm form resubmission" and pay twice; others double-click Pay on a slow connection. Why does each happen, and how do you make a second payment for the same bill impossible?
-
Mid to senior
A mobile app needs POST /api/notices/ to create a notice from a JSON body, and the team does not want an API framework yet. Describe the plain Django view: what it checks, in which order, and which status code it answers in each case.
Key takeaways
- A view gets one
HttpRequestand returns oneHttpResponse; the request carries the method, the path, the query string, headers in any case, cookies and the body, all as the client sent them, unconverted. request.GETandrequest.POSTare QueryDicts: indexing andget()give a key’s last value,getlist()all of them, andrequest.POSTstays empty for a JSON body, which is inrequest.body.- Choose the response class for the body,
HttpResponse,JsonResponse,FileResponseor a redirect, and the status code for what happened, such as 201, 404, 405, 409 or 422. render(),redirect()andget_object_or_404()do in one line what most views repeat, and the last one turns a missing row into a 404 instead of a 500.- The method decorators answer methods a view does not handle with 405 and an
Allowheader;require_safeallows GET and HEAD. - After a successful POST, redirect: the browser’s last request is then a GET, and refreshing never sends the form again.
Terms in this lesson
- HttpRequest
- The object Django builds for each request and passes to the view as its first argument: the method, the path, GET, POST, the headers, COOKIES and the body, and later attributes that middleware adds, such as user.
- Post/Redirect/Get
- Answering a successful form POST with a redirect to a page fetched with GET, so that the browser's last request is the GET and refreshing the page never sends the form again.
- QueryDict
- Django's dictionary for a query string or form data, as in request.GET and request.POST: one key can hold several values, getlist() returns them all, and the copies a request gives a view cannot be changed.
- status code
- The three-digit number at the start of an HTTP response that says what happened, such as 200 OK, 201 Created, 302 Found for a redirect, 404 Not Found or 405 Method Not Allowed.
References
- Django documentation: Writing views (Django Software Foundation)
- Request and response objects (Django Software Foundation)
- Django shortcut functions (Django Software Foundation)
- View decorators (Django Software Foundation)
- Django 6.1 release notes (Django Software Foundation)
- Django 6.2 release notes (in development) (Django Software Foundation)
- Testing tools: the test client (Django Software Foundation)
- Cross Site Request Forgery protection (Django Software Foundation)
- RFC 9110: HTTP Semantics (IETF)
- Error Handling Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress