Skip to content

The note form showed one mistake at a time, and saved spaces as a title

  • Frameworks · Core
  • 45 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: high
  • Covers Django 5.2, Python 3.11+
  • Reviewed 2026-11

The Notes app has an HTML form to edit a note. Ana leaves the title empty and types maybe in the pinned box. The page says “title is required”. She fixes it and saves. Now it says “pinned must be true or false”. One mistake at a time, one round trip each.

Then Ben types three spaces as a title, and the note is saved with a blank name. A checkbox sends "on", which the view stores as the text "on", not True. All of this is code the team wrote by hand, while Django ships a tool for exactly this job.

Write validate_note(data), the way a Django form checks input.

  • Input: a dict as sent by the browser or a JSON client: title, and maybe body and pinned.
  • Output: a pair (cleaned, errors). cleaned holds typed values when all is well, or {}. errors maps each field to its messages.
  • The rules: the title is required after trimming spaces, at most 200 characters. body is text, default "". pinned is a bool; "on", "true" and "1" mean True, and "off", "false", "0" and "" mean False.
  • The hard part: report every problem in one go, and hand back data ready to save.

Example: validate_note({"title": " Tea ", "pinned": "on"}) gives ({"title": "Tea", "body": "", "pinned": True}, {}).

Hints open one at a time:

Hint 1 of 3

Raising an error ends the function. To keep going, write the problem down instead.

Hint 2 of 3

A dict from field name to a list of messages lets the page show each one next to its box.

Hint 3 of 3

Build the cleaned values as you go: trimmed text, a real bool. Return them only if there were no errors.

Django comes with the parts a web app needs. Flask asks you to choose; Django has made the choices. The Notes API is in examples/django/notes; start it with ./run.sh examples django.

A project holds apps. django-admin startproject config makes the settings, the root URLconf and the WSGI and ASGI entry points. manage.py startapp notes makes an app: one feature, with its own models, views, URLs, admin, migrations and tests. Add it to INSTALLED_APPS.

MTV is Django’s name for its layers:

  • Model: a class per table, in models.py. title = models.CharField(max_length=200).
  • Template: HTML with {{ note.title }}, escaped by default. An API returns JSON instead.
  • View: a function (or class) that takes a request and returns a response.

URLconfs map paths to views. path("notes/<int:pk>/", views.note_detail). The project’s urls.py includes each app’s: path("api/", include("notes.urls")).

The ORM and migrations. Note.objects.filter(owner=request.user).order_by("-updated_at") builds a lazy QuerySet; the SQL runs when you read the rows. Change a model, run manage.py makemigrations, then manage.py migrate. The second practice problem builds Django’s field__lookup filters.

The admin is a full editing site for your models, from three lines in admin.py: admin.site.register(Note). It is for staff, not users.

Forms check input. A Form or ModelForm lists fields; form.is_valid() runs every check, then form.errors has each problem by field and form.cleaned_data has typed values. DRF’s serializers do the same for JSON.

Middleware and auth. settings.MIDDLEWARE lists the layers each request passes through: security headers, sessions, CSRF, authentication and more. request.user is the logged-in user; @login_required guards a view; authenticate() and login() check a password and start a session.

One request through Django

PATCH /api/notes/7/ in Django

Parts:

  • Browser (the request is here)
  • Middleware: session, CSRF, auth
  • URLconf: notes/<int:pk>/
  • View: note_detail
  • ORM: Note.objects
  • Database

Arrows:

  • Browser sends requests to Middleware: session, CSRF, auth
  • Middleware: session, CSRF, auth sends requests to URLconf: notes/<int:pk>/
  • URLconf: notes/<int:pk>/ sends requests to View: note_detail
  • View: note_detail sends requests to ORM: Note.objects
  • ORM: Note.objects sends requests to Database

Step 1 of 7. The browser sends PATCH /api/notes/7/ with {"title": ""}, the session cookie and the CSRF token.

Three versions of validate_note. The tests send several mistakes at once, spaces as a title, and a checkbox’s "on".

Okay

Raise at the first problem

The idea: Check each field in turn and raise ValueError on the first one that is wrong.

It stops bad data from being saved.

def validate_note(data: dict) -> tuple[dict, dict[str, list[str]]]:
if not data.get("title"):
raise ValueError("title: This field is required.") # stops at the first problem
if len(data["title"]) > 200:
raise ValueError("title: Ensure this value has at most 200 characters.")
if data.get("pinned") not in (None, True, False):
raise ValueError("pinned: Enter true or false.")
return data, {}
Errors shown
One at a time
Data saved
As sent
Why
Raising ends the function, so later fields are never checked.

Where it breaks: two mistakes take two round trips. And what it returns is the data as sent: " Tea " keeps its spaces, "on" stays text.

Better

Collect every error

The idea: Write each problem into an errors dict, field by field, and return them all.

The waste in Okay: one round trip per mistake.

Highlighted lines are new in this rung.

def validate_note(data: dict) -> tuple[dict, dict[str, list[str]]]:
errors: dict[str, list[str]] = {}
title = data.get("title")
if not title:
errors.setdefault("title", []).append("This field is required.")
elif len(title) > 200:
errors.setdefault("title", []).append("Ensure this value has at most 200 characters.")
if data.get("pinned") not in (None, True, False):
errors.setdefault("pinned", []).append("Enter true or false.")
return (data if not errors else {}), errors # every problem at once, but data as sent
Errors shown
All at oncewas One at a time
Data saved
As sent
Why
The page can show every message next to its field in one go.
What changed from the previous rung
def validate_note(data: dict) -> tuple[dict, dict[str, list[str]]]:
if not data.get("title"):
raise ValueError("title: This field is required.") # stops at the first problem
if len(data["title"]) > 200:
raise ValueError("title: Ensure this value has at most 200 characters.")
errors: dict[str, list[str]] = {}
title = data.get("title")
if not title:
errors.setdefault("title", []).append("This field is required.")
elif len(title) > 200:
errors.setdefault("title", []).append("Ensure this value has at most 200 characters.")
if data.get("pinned") not in (None, True, False):
raise ValueError("pinned: Enter true or false.")
return data, {}
errors.setdefault("pinned", []).append("Enter true or false.")
return (data if not errors else {}), errors # every problem at once, but data as sent

Where it breaks: it checks, but doesn’t clean. Three spaces pass as a title, "on" is refused, and the saved title keeps its spaces.

Best

Check, clean and type each field

The idea: For each field: read it, normalize it (trim, turn on into True), check it, and store the clean value or an error.

The waste in Better: it checked the raw text, so it judged and saved the wrong values.

Highlighted lines are new in this rung.

def validate_note(data: dict) -> tuple[dict, dict[str, list[str]]]:
errors: dict[str, list[str]] = {}
cleaned: dict = {}
title = data.get("title")
title = title.strip() if isinstance(title, str) else ""
if not title:
errors["title"] = ["This field is required."]
elif len(title) > 200:
errors["title"] = ["Ensure this value has at most 200 characters."]
cleaned["title"] = title
body = data.get("body", "")
if not isinstance(body, str):
errors["body"] = ["Enter text."]
cleaned["body"] = body if isinstance(body, str) else ""
pinned = data.get("pinned", False)
if isinstance(pinned, str): # an HTML checkbox sends "on"; JSON clients send true
pinned = {
"on": True,
"true": True,
"1": True,
"off": False,
"false": False,
"0": False,
"": False,
}.get(pinned.lower(), pinned)
if not isinstance(pinned, bool):
errors["pinned"] = ["Enter true or false."]
cleaned["pinned"] = pinned
return ({} if errors else cleaned), errors
Errors shown
All at once
Data saved
Trimmed and typedwas As sent
Why
This is what a Django form does: errors by field, and cleaned_data ready to save.
What changed from the previous rung
def validate_note(data: dict) -> tuple[dict, dict[str, list[str]]]:
errors: dict[str, list[str]] = {}
cleaned: dict = {}
title = data.get("title")
title = title.strip() if isinstance(title, str) else ""
if not title:
errors.setdefault("title", []).append("This field is required.")
errors["title"] = ["This field is required."]
elif len(title) > 200:
errors.setdefault("title", []).append("Ensure this value has at most 200 characters.")
if data.get("pinned") not in (None, True, False):
errors.setdefault("pinned", []).append("Enter true or false.")
return (data if not errors else {}), errors # every problem at once, but data as sent
errors["title"] = ["Ensure this value has at most 200 characters."]
cleaned["title"] = title
body = data.get("body", "")
if not isinstance(body, str):
errors["body"] = ["Enter text."]
cleaned["body"] = body if isinstance(body, str) else ""
pinned = data.get("pinned", False)
if isinstance(pinned, str): # an HTML checkbox sends "on"; JSON clients send true
pinned = {
"on": True,
"true": True,
"1": True,
"off": False,
"false": False,
"0": False,
"": False,
}.get(pinned.lower(), pinned)
if not isinstance(pinned, bool):
errors["pinned"] = ["Enter true or false."]
cleaned["pinned"] = pinned
return ({} if errors else cleaned), errors

When Best isn’t best: use a ModelForm. It reads the rules from the model (max_length=200, blank=False), trims CharFields for you, and its BooleanField reads checkboxes. For JSON APIs, a DRF serializer does the same.

Write validate_note(data) from the task. Press Run tests to check your code.

  • title: trim it. Empty means ["This field is required."]; over 200 means ["Ensure this value has at most 200 characters."].
  • body: default ""; anything not text means ["Enter text."].
  • pinned: default False. Text on, true or 1 is True; off, false, 0 or empty is False, in any case. Anything else means ["Enter true or false."].
  • Return (cleaned, {}), or ({}, errors) if there are any.

Check a note like a Django form

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: validate_note({'title': 'Tea'}) → [{'title': 'Tea', 'body': '', 'pinned': False}, {}]
  2. Not run yet: Two mistakes at once → [{}, {'title': ['This field is required.'], 'pinned': ['Enter true or false.']}]
  3. Not run yet: Spaces, and a checkbox’s "on" → [{'title': 'Tea', 'body': '', 'pinned': True}, {}]
  4. Not run yet: A title of three spaces → [{}, {'title': ['This field is required.']}]
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test
  8. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayErrors shownData savedGoodNot so goodUse it when
Okay: raise at the firstOne at a timeAs sentShortMany round trips; dirty dataA one-field check
Better: collect errorsAll at onceAs sentGood messagesSpaces and "on" handled wrongChecking data you won’t save
Best: check, clean, typeAll at onceTrimmed and typedWhat forms doMore code by handNever by hand in Django: use a form
  • Forgetting owner=request.user. Note.objects.get(pk=pk) lets anyone open any note. Filter by owner, and answer 404.
  • A QuerySet in a loop that reads a foreign key. One query per row: the N+1 problem. See The order list page is slow.
  • Logic in templates or views that belongs in the model. Put rules about a note on Note, so the admin, forms and API share them.
  • DEBUG = True in production. Error pages show settings and code. Set DEBUG = False and ALLOWED_HOSTS, and run manage.py check --deploy.
  • Editing the database by hand. Make a migration, so every environment gets the same change.

Questions to ask first

  • Is this a site with pages, an API, or both?
  • Who edits the data: users, or staff who could use the admin?
  • Which parts of Django will we lean on: ORM, admin, auth, forms?

A 60-second walkthrough

“A Django project is a set of apps, each with models, views, URLs and migrations. A request goes through the middleware: security, sessions, CSRF, auth. Then the URLconf picks a view, which uses the ORM and returns a template or JSON. Models define tables, makemigrations writes the change and migrate applies it. I check input with forms or DRF serializers, which give every error at once and typed cleaned data. I’d use the built-in auth and login_required, and filter every query by the user. I watch QuerySets for N+1, with select_related and prefetch_related. The admin gives staff an editing site at no extra cost.”

Likely follow-ups

  • What is a QuerySet? A lazy query you can keep chaining. It runs when you read it, then caches the rows.
  • ForeignKey vs ManyToManyField? A foreign key is one related row per row, like a note’s owner; many-to-many uses a join table, like notes and tags.
  • Function views or class-based views? Functions are plain and easy to read. Class-based views and generics save code for standard list and edit pages.
  • How does Django stop CSRF? CsrfViewMiddleware checks a token from the cookie against one in the form or the X-CSRFToken header.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

Write pluralize(count, suffix="s"), Django’s template filter. It gives the ending for a word after a count: "" for one, suffix otherwise. If the suffix has a comma, like "y,ies", the part before it is for one and the part after it for many.

Example: pluralize(0) is "s", for “0 notes”. pluralize(1, "y,ies") is "y", for “1 entry”.

Hint 1 of 3

Zero is plural in English: “0 notes”.

Hint 2 of 3

Only exactly one is singular.

Hint 3 of 3

suffix.split(",") gives the one and many endings when there is a comma.

The pluralize filter

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: 2 note… → 's'
  2. Not run yet: 0 note… → 's'
  3. Not run yet: 1 note… → ''
  4. Not run yet: 1 entr… → 'y'
  5. Not run yet: 5 entr… → 'ies'
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

More than one

The idea: Add the suffix when the count is over 1.

Right for 1, 2 and more.

def pluralize(count: int, suffix: str = "s") -> str:
return suffix if count > 1 else ""
0 notes
“0 note”
1 entry
“1 entry”
Why
It treats plural as “more than one”.

Where it breaks: zero is plural in English, so it shows “0 note”. And "y,ies" comes out whole.

Better

Anything but one

The idea: Add the suffix unless the count is exactly 1.

The waste in Okay: it forgot zero.

Highlighted lines are new in this rung.

def pluralize(count: int, suffix: str = "s") -> str:
return suffix if count != 1 else ""
0 notes
“0 notes”was “0 note”
5 entries
“5 entry,ies”was “1 entry”
Why
Only one is singular.
What changed from the previous rung
def pluralize(count: int, suffix: str = "s") -> str:
return suffix if count > 1 else ""
return suffix if count != 1 else ""

Where it breaks: words that change their ending, like entry and entries, need two endings, and Better pastes the whole suffix.

Best

One ending and a many ending

The idea: Split the suffix on a comma into the one and many endings; use the one ending only for 1.

The waste in Better: it only knew words that add a letter.

Highlighted lines are new in this rung.

def pluralize(count: int, suffix: str = "s") -> str:
# Like Django's filter: "s", "es", or "y,ies" for one word and many.
one, many = suffix.split(",") if "," in suffix else ("", suffix)
return one if count == 1 else many
0 notes
“0 notes”
5 entries
“5 entries”was “5 entry,ies”
Why
It is Django’s rule: suffix, or singular,plural.
What changed from the previous rung
def pluralize(count: int, suffix: str = "s") -> str:
return suffix if count != 1 else ""
# Like Django's filter: "s", "es", or "y,ies" for one word and many.
one, many = suffix.split(",") if "," in suffix else ("", suffix)
return one if count == 1 else many

When Best isn’t best: in a template, write {{ count }} entr{{ count|pluralize:"y,ies" }}. For other languages, use Django’s translation ngettext, because plural rules differ.

Write filter_rows(rows, **lookups), like QuerySet.filter. Each key is a field, or field__op. Support exact (the default), contains, icontains, in, gt, gte, lt and lte. Keep rows that pass every lookup. Raise ValueError for any other op, naming it.

Example: filter_rows(ROWS, title__icontains="milk") keeps rows 1 and 3.

Hint 1 of 3

key.partition("__") splits "stars__gte" into "stars", "__", "gte"; for "id" the op is "".

Hint 2 of 3

A dict from op name to a function of (field_value, value) keeps it short. operator.ge and friends help.

Hint 3 of 3

Check every key before filtering, so a typo fails even on an empty list.

Django-style lookups

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: id=2 → [{'id': 2, 'title': 'Call mum', 'stars': 5}]
  2. Not run yet: title__icontains="milk" → [{'id': 1, 'title': 'Buy Milk', 'stars': 3}, {'id': 3, 'title': 'milk again', 'stars': 1}]
  3. Not run yet: id__in=[1, 3] → [{'id': 1, 'title': 'Buy Milk', 'stars': 3}, {'id': 3, 'title': 'milk again', 'stars': 1}]
  4. Not run yet: stars__gte=3, id__lt=3 → [{'id': 1, 'title': 'Buy Milk', 'stars': 3}, {'id': 2, 'title': 'Call mum', 'stars': 5}]
  5. Not run yet: title__startswith="B" → raises ValueError
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Exact matches only

The idea: Compare row.get(key) with the value.

Right for id=2.

def filter_rows(rows: list[dict], **lookups) -> list[dict]:
return [row for row in rows if all(row.get(k) == v for k, v in lookups.items())]
Ops
exact
A typo
Finds nothing
Why
It reads title__contains as a field name.

Where it breaks: title__contains isn’t a field, so row.get gives None and nothing matches. No error says why.

Better

Split on __

The idea: Split the key, and handle contains and in; treat anything else as exact.

The waste in Okay: it never looked inside the key.

Highlighted lines are new in this rung.

def filter_rows(rows: list[dict], **lookups) -> list[dict]:
def ok(row: dict, key: str, value) -> bool:
field, _, op = key.partition("__")
if op == "contains":
return value in row[field]
if op == "in":
return row[field] in value
return row[field] == value # anything else is treated as an exact match
return [row for row in rows if all(ok(row, k, v) for k, v in lookups.items())]
Ops
exact, contains, inwas exact
A typo
Wrong rowswas Finds nothing
Why
It understands the field__op shape.
What changed from the previous rung
def filter_rows(rows: list[dict], **lookups) -> list[dict]:
return [row for row in rows if all(row.get(k) == v for k, v in lookups.items())]
def ok(row: dict, key: str, value) -> bool:
field, _, op = key.partition("__")
if op == "contains":
return value in row[field]
if op == "in":
return row[field] in value
return row[field] == value # anything else is treated as an exact match
return [row for row in rows if all(ok(row, k, v) for k, v in lookups.items())]

Where it breaks: stars__gte=3 quietly becomes stars == 3, which gives wrong rows with no error.

Best

A table of ops, and refuse the rest

The idea: Map each op name to a test function; check every key first and raise on an unknown op.

The waste in Better: it guessed when it didn’t know.

Highlighted lines are new in this rung.

import operator
OPS = {
"exact": operator.eq,
"contains": lambda field, value: value in field,
"icontains": lambda field, value: value.lower() in field.lower(),
"in": lambda field, value: field in value,
"gt": operator.gt,
"gte": operator.ge,
"lt": operator.lt,
"lte": operator.le,
}
def filter_rows(rows: list[dict], **lookups) -> list[dict]:
checks = []
for key, value in lookups.items():
field, _, op = key.partition("__")
if (op or "exact") not in OPS:
raise ValueError(f"Unsupported lookup '{op}' for {field}")
checks.append((field, OPS[op or "exact"], value))
return [row for row in rows if all(test(row[f], v) for f, test, v in checks)]
Ops
All eightwas exact, contains, in
A typo
ValueErrorwas Wrong rows
Why
Adding an op is one line, and a mistake fails loudly.
What changed from the previous rung
import operator
OPS = {
"exact": operator.eq,
"contains": lambda field, value: value in field,
"icontains": lambda field, value: value.lower() in field.lower(),
"in": lambda field, value: field in value,
"gt": operator.gt,
"gte": operator.ge,
"lt": operator.lt,
"lte": operator.le,
}
def filter_rows(rows: list[dict], **lookups) -> list[dict]:
def ok(row: dict, key: str, value) -> bool:
checks = []
for key, value in lookups.items():
field, _, op = key.partition("__")
if op == "contains":
return value in row[field]
if op == "in":
return row[field] in value
return row[field] == value # anything else is treated as an exact match
return [row for row in rows if all(ok(row, k, v) for k, v in lookups.items())]
if (op or "exact") not in OPS:
raise ValueError(f"Unsupported lookup '{op}' for {field}")
checks.append((field, OPS[op or "exact"], value))
return [row for row in rows if all(test(row[f], v) for f, test, v in checks)]

When Best isn’t best: Django turns these into SQL, so the database does the work with indexes. Filtering in Python means loading every row first. Django also follows relations, like owner__username="ana".

Write get_page(items, number, per_page), like Paginator.get_page. Give back items, number, pages, has_next and has_previous. An empty list has one page. If number isn’t a whole number, like "abc" or None, show page 1. If it is below 1 or past the end, show the last page.

Example: 45 items, 20 a page, number="9" shows page 3.

Hint 1 of 3

Pages: -(-len(items) // per_page), the ceiling, but at least 1.

Hint 2 of 3

int(number) inside try, catching TypeError and ValueError.

Hint 3 of 3

Out of range means the last page in Django’s get_page, even for 0.

Django’s get_page

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: 45 items, page "2" → {'items': [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40], 'number': 2, 'pages': 3, 'has_next': True, 'has_previous': True}
  2. Not run yet: 45 items, page "9" → {'items': [41, 42, 43, 44, 45], 'number': 3, 'pages': 3, 'has_next': False, 'has_previous': True}
  3. Not run yet: 45 items, page "abc" → {'items': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], 'number': 1, 'pages': 3, 'has_next': True, 'has_previous': False}
  4. Not run yet: No items → {'items': [], 'number': 1, 'pages': 1, 'has_next': False, 'has_previous': False}
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Slice by the number

The idea: Turn the number into an int and slice.

Right for the pages that exist.

def get_page(items: list, number, per_page: int) -> dict:
number = int(number)
start = (number - 1) * per_page
return {"items": items[start : start + per_page], "number": number}
?page=9
An empty page
?page=abc
Crashes
Why
It trusts the number in the URL.

Where it breaks: anyone can type ?page=abc, which crashes with a 500, or ?page=9, which shows nothing.

Better

Clamp to 1 through pages

The idea: Count the pages and clamp the number into range.

The waste in Okay: it never checked the range.

Highlighted lines are new in this rung.

def get_page(items: list, number, per_page: int) -> dict:
pages = max(1, -(-len(items) // per_page))
number = min(max(int(number), 1), pages) # clamp to 1..pages
start = (number - 1) * per_page
return {"items": items[start : start + per_page], "number": number}
?page=9
The last pagewas An empty page
?page=abc
Crashes
Why
Numbers past the end now show real notes.
What changed from the previous rung
def get_page(items: list, number, per_page: int) -> dict:
number = int(number)
pages = max(1, -(-len(items) // per_page))
number = min(max(int(number), 1), pages) # clamp to 1..pages
start = (number - 1) * per_page
return {"items": items[start : start + per_page], "number": number}

Where it breaks: text still crashes, and there is no has_next for the template’s Next link.

Best

Forgive any input, and say what’s around

The idea: Parse safely (page 1 if not a number), send out-of-range to the last page, and add pages, has_next and has_previous.

The waste in Better: it fixed the range but not the type.

Highlighted lines are new in this rung.

def get_page(items: list, number, per_page: int) -> dict:
pages = max(1, -(-len(items) // per_page)) # ceiling division; an empty list has 1 page
try:
number = int(number)
except (TypeError, ValueError):
number = 1 # ?page=abc or no page at all
if number < 1 or number > pages:
number = pages # Django's get_page gives the last page for any number out of range
start = (number - 1) * per_page
return {
"items": items[start : start + per_page],
"number": number,
"pages": pages,
"has_next": number < pages,
"has_previous": number > 1,
}
?page=9
The last page
?page=abc
Page 1was Crashes
Why
Every URL gives a page, and the template can draw its links.
What changed from the previous rung
def get_page(items: list, number, per_page: int) -> dict:
pages = max(1, -(-len(items) // per_page))
number = min(max(int(number), 1), pages) # clamp to 1..pages
pages = max(1, -(-len(items) // per_page)) # ceiling division; an empty list has 1 page
try:
number = int(number)
except (TypeError, ValueError):
number = 1 # ?page=abc or no page at all
if number < 1 or number > pages:
number = pages # Django's get_page gives the last page for any number out of range
start = (number - 1) * per_page
return {"items": items[start : start + per_page], "number": number}
return {
"items": items[start : start + per_page],
"number": number,
"pages": pages,
"has_next": number < pages,
"has_previous": number > 1,
}

When Best isn’t best: use Paginator(qs, 20).get_page(request.GET.get("page")). It slices the QuerySet in SQL, with LIMIT and OFFSET. For long lists that change, cursor paging, from the REST lesson, is steadier.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choiceIn Django’s MTV, which part decides what happens for a request and returns a response?
  2. Predict the outputHow many queries run here?
    notes = Note.objects.filter(owner=user)
    notes = notes.order_by("-updated_at")
    first = notes[:5]
  3. Spot the bugWhat is wrong with this view?
    @login_required
    def note_detail(request, pk):
        note = Note.objects.get(pk=pk)
        return JsonResponse(note.as_dict())
  4. Multiple choiceYou changed a model field. What are the next two commands?
  5. Fill in the answerAfter form.is_valid(), the typed, trimmed values are in form.____

Add these to your review deck. Each card comes back a little later every time you get it right.

  • Project = settings + root URLconf; apps = features (models, views, urls, admin, migrations, tests).
  • MTV: models (tables), templates (HTML, escaped), views (request in, response out).
  • makemigrations then migrate; QuerySets are lazy; filter every query by request.user.
  • Forms: is_valid(), errors by field, cleaned_data typed. DRF serializers for JSON.
  • Built in: admin, auth (login_required), sessions, CSRF, Paginator, pluralize.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started